/*
 * 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.struts2.views.gxp;

import com.google.common.annotations.VisibleForTesting;
import com.google.gxp.base.GxpContext;
import com.google.gxp.base.MarkupClosure;
import com.opensymphony.xwork2.ActionContext;
import com.opensymphony.xwork2.inject.Inject;
import com.opensymphony.xwork2.util.ValueStack;
import com.opensymphony.xwork2.util.ValueStackFactory;

import java.io.PrintWriter;
import java.io.StringWriter;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;

/**
 * Struts2 to GXP adapter. Can be used to write a GXP or create a
 * {@link MarkupClosure}. Pulls GXP parameters from Struts2 value stack.
 *
 * @author Bob Lee
 */
public abstract class AbstractGxp<T extends MarkupClosure> {

    ValueStackFactory valueStackFactory;
    Map defaultValues = new HashMap();
    List<Param> params;
    Class gxpClass;
    Method writeMethod;
    Method getGxpClosureMethod;
    boolean hasBodyParam;

    protected AbstractGxp(Class gxpClass) {
        this(gxpClass, lookupMethodByName(gxpClass, "write"), lookupMethodByName(gxpClass, "getGxpClosure"));
    }

    protected AbstractGxp(Class gxpClass, Method writeMethod, Method getGxpClosureMethod) {
        this.gxpClass = gxpClass;
        this.writeMethod = writeMethod;
        this.getGxpClosureMethod = getGxpClosureMethod;
        this.params = lookupParams();
    }

    /**
     * Writes GXP. Pulls GXP parameters from Struts2's value stack.
     *
     * @param out Appendable
     * @param gxpContext GXP content
     */
    public void write(Appendable out, GxpContext gxpContext) {
        write(out, gxpContext, null);
    }

    /**
     * Writes GXP. Pulls GXP parameters from Struts2's value stack.
     *
     * @param out Appendable
     * @param gxpContext GXP content
     * @param overrides parameter map pushed onto the value stack
     */
    protected void write(Appendable out, GxpContext gxpContext, Map overrides) {
        Object[] args = getArgs(out, gxpContext, overrides);

        try {
            writeMethod.invoke(getGxpInstance(), args);
        } catch (Exception e) {
            throw new RuntimeException(createDebugString(args, e), e);
        }
    }

    protected Object[] getArgs(Appendable out, GxpContext gxpContext, Map overrides) {
        List<Object> argList = getArgListFromValueStack(overrides);
        Object[] args = new Object[argList.size() + 2];
        args[0] = out;
        args[1] = gxpContext;
        int index = 2;
        for (Iterator<Object> i = argList.iterator(); i.hasNext(); index++) {
            args[index] = i.next();
        }
        return args;
    }

    /**
     * @return the object on which to call the write and getGxpClosure methods. If
     *         the methods are static, this can return {@code null}
     */
    protected Object getGxpInstance() {
        return null;
    }

    /**
     * @return Creates GXP closure. Pulls GXP parameters from Struts 2 value stack.
     */
    public T getGxpClosure() {
        return getGxpClosure(null, null);
    }

    /**
     * @return  Creates GXP closure. Pulls GXP parameters from Struts 2 value stack.
     *
     * @param body   is pushed onto the stack if this GXP has a
     *               {@link MarkupClosure} (or subclass) parameter named "body".
     * @param params comes first on the value stack.
     */
    @SuppressWarnings("unchecked")
    protected T getGxpClosure(T body, Map params) {
        final Map overrides = getOverrides(body, params);

        Object[] args = getArgListFromValueStack(overrides).toArray();

        try {
            return (T) getGxpClosureMethod.invoke(getGxpInstance(), args);
        } catch (IllegalArgumentException e) {
            throw new RuntimeException(createDebugString(args, e), e);
        } catch (IllegalAccessException e) {
            throw new RuntimeException(createDebugString(args, e), e);
        } catch (InvocationTargetException e) {
            throw new RuntimeException(createDebugString(args, e), e);
        }
    }

    protected Map getOverrides(T body, Map params) {
        final Map overrides = new HashMap();
        if (hasBodyParam && body != null) {
            overrides.put(Param.BODY_PARAM_NAME, body);
        }
        if (params != null) {
            overrides.putAll(params);
        }
        return overrides;
    }

    /**
     * Iterates over GXP parameters, pulls value from value stack for each
     * parameter, and appends the values to an argument list which will
     * be passed to a method on a GXP.
     *
     * @param overrides parameter map pushed onto the value stack
     *
     * @return list of arguments
     */
    List getArgListFromValueStack(Map overrides) {

        ValueStack valueStack = valueStackFactory.createValueStack(ActionContext.getContext().getValueStack());

        // add default values to the bottom of the stack. if no action provides
        // a getter for a param, the default value will be used.
        valueStack.getRoot().add(this.defaultValues);

        // push override parameters onto the stack.
        if (overrides != null && !overrides.isEmpty()) {
            valueStack.push(overrides);
        }

        List args = new ArrayList(params.size());
        for (Param param : getParams()) {
            try {
                args.add(valueStack.findValue(param.getName(), param.getType()));
            } catch (Exception e) {
                throw new RuntimeException("Exception while finding '" + param.getName() + "'.", e);
            }
        }

        return args;
    }

    /**
     * Combines parameter names and types into <code>Param</code> objects.
     *
     * @return parameter list
     */
    List<Param> lookupParams() {
        List<Param> params = new ArrayList<Param>();

        List<String> parameterNames = lookupParameterNames();
        List<Class<?>> parameterTypes = lookupParameterTypes();
        Iterator<Class<?>> parameterTypeIterator = parameterTypes.iterator();

        // If there are more parameter names than parameter types it means that we are
        // using instantiable GXPs and there are 1 or more constructor parameters.
        // Constructor params will always be first in the list, so just drop an appropriate
        // number of elements from the beginning of the list.
        if (parameterNames.size() > parameterTypes.size()) {
            parameterNames = parameterNames.subList(parameterNames.size() - parameterTypes.size(), parameterNames.size());
        }

        for (String name : parameterNames) {
            Class paramType = parameterTypeIterator.next();
            Param param = new Param(gxpClass, name, paramType);
            params.add(param);

            if (param.isBody()) {
                hasBodyParam = true;
            }

            if (param.isOptional()) {
                defaultValues.put(param.getName(), param.getDefaultValue());
            }
        }

        this.defaultValues = Collections.unmodifiableMap(this.defaultValues);
        return Collections.unmodifiableList(params);
    }

    /**
     * @return  list of parameter types.
     */
    List<Class<?>> lookupParameterTypes() {
        List<Class<?>> parameterTypes = Arrays.asList(writeMethod.getParameterTypes());
        // skip the first two, gxp_out and gxp_context. they are for internal use.
        return parameterTypes.subList(2, parameterTypes.size());
    }

    /**
     * @return  list of parameter names.
     */
    List<String> lookupParameterNames() {
        try {
            return (List<String>) gxpClass.getMethod("getArgList").invoke(null);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

    /**
     * @param clazz class
     * @param name method name for lookup
     *
     * @return  first method with the given name. Should not be used if the
     * method is overloaded.
     */
    protected static Method lookupMethodByName(Class clazz, String name) {
        Method[] methods = clazz.getMethods();
        for (int i = 0; i < methods.length; i++) {
            if (methods[i].getName().equals(name)) {
                return methods[i];
            }
        }
        throw new RuntimeException("No " + name + "(...) method found for "
                + clazz.getName() + ".");
    }

    public Class getGxpClass() {
        return this.gxpClass;
    }

    /**
     * @return  list of parameters requested by GXP.
     */
    public List<Param> getParams() {
        return params;
    }

    /**
     * @param gxpPath Gxp path
     *
     * @return  generated GXP class given an absolute path to a GXP file.
     * The current implementation assumes that the GXP and generated Java source
     * file share the same name with different extensions.
     */
    @VisibleForTesting
    public static Class getGxpClassForPath(String gxpPath) {
        int offset = (gxpPath.charAt(0) == '/') ? 1 : 0;
        String className = gxpPath.substring(offset, gxpPath.length() - 4).replace('/', '.');
        try {
            return getClassLoader().loadClass(className);
        } catch (ClassNotFoundException e) {
            throw new RuntimeException(e);
        }
    }

    static ClassLoader getClassLoader() {
        ClassLoader loader = Thread.currentThread().getContextClassLoader();
        return (loader == null) ? ClassLoader.getSystemClassLoader() : loader;
    }

    /**
     * @param args arguments
     * @param exception Exception
     *
     * @return debug String which can be tacked onto an exception.
     */
    String createDebugString(Object[] args, Exception exception) {
        StringBuffer buffer = new StringBuffer();
        printExceptionTraceToBuffer(exception, buffer);
        buffer.append("\nException in GXP: ").append(gxpClass.getName()).append(". Params:");
        int index = 2;
        for (Param param : getParams()) {
            try {
                Object arg = args[index++];
                String typesMatch = "n/a (null)";
                if (arg != null) {
                    if (doesArgumentTypeMatchParamType(param, arg)) {
                        typesMatch = "YES";
                    } else {
                        typesMatch = "NO";
                    }
                }
                buffer.append("\n  ")
                        .append(param.toString())
                        .append(" = ")
                        .append(arg)
                        .append("; ")
                        .append("[types match? ")
                        .append(typesMatch)
                        .append("]");
            } catch (Exception e) {
                buffer.append(" >Error getting information for param # ").append(index).append("< ");
            }
        }
        buffer.append("\nStack trace: ");
        return buffer.toString();
    }

    private void printExceptionTraceToBuffer(Exception e,
                                             StringBuffer buffer) {
        StringWriter out = new StringWriter();
        e.printStackTrace(new PrintWriter(out));
        buffer.append(out.getBuffer().toString());
    }

    private boolean doesArgumentTypeMatchParamType(Param param, Object arg) {
        Class paramType = param.getType();
        Class<? extends Object> argClass = arg.getClass();

        // TODO(jpelly): Handle all primitive unwrapping (ie, Boolean --> boolean).
        if (boolean.class.equals(paramType) && Boolean.class.equals(argClass)) {
            return true;
        } else if (char.class.equals(paramType) && Character.class.equals(argClass)) {
            return true;
        }
        return paramType.isAssignableFrom(argClass);
    }

    @Inject
    public void setValueStackFactory(ValueStackFactory valueStackFactory) {
        this.valueStackFactory = valueStackFactory;
    }
}
