package org.apache.velocity.tools;

/*
 * 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.
 */

import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.HashMap;
import java.util.Map;
import org.apache.commons.beanutils.PropertyUtils;
import org.apache.velocity.tools.config.SkipSetters;

/**
 * Manages data needed to create instances of a tool. New instances
 * are returned for every call to create(obj).
 *
 * @author Nathan Bubna
 * @author <a href="mailto:[email protected]">Henning P. Schmiedehausen</a>
 * @version $Id: ToolInfo.java 511959 2007-02-26 19:24:39Z nbubna $
 */
public class ToolInfo implements java.io.Serializable
{
    private static final long serialVersionUID = -8145087882015742757L;
    public static final String CONFIGURE_METHOD_NAME = "configure";

    private String key;
    private Class clazz;
    private Class factory;
    private transient Method create;
    private boolean restrictToIsExact;
    private String restrictTo;
    private Map<String,Object> properties;
    private Boolean skipSetters;
    private transient Method configure = null;

    /**
     * Creates a new instance using the minimum required info
     * necessary for a tool.
     * @param key tool key
     * @param clazz tool class
     */
    public ToolInfo(String key, Class clazz)
    {
        this(key, clazz, null);
    }

    /**
     * Creates a new instance using the minimum required info
     * necessary for a tool.
     * @param key tool key
     * @param clazz tool class
     */
    public ToolInfo(String key, Class clazz, Class factory)
    {
        setKey(key);
        setClass(clazz);
        setFactory(factory);
    }


    /***********************  Mutators *************************/

    /**
     * Set the tool key
     * @param key tool key
     */
    public void setKey(String key)
    {
        this.key = key;
        if (this.key == null)
        {
            throw new NullPointerException("Key cannot be null");
        }
    }

    /**
     * Set the tool class
     * @param clazz the java.lang.Class of the tool
     */
    public void setClass(Class clazz)
    {
        if (clazz == null)
        {
            throw new NullPointerException("Tool class must not be null");
        }
        this.clazz = clazz;

        //NOTE: we used to check here that we could get an instance of
        //      the tool class, but that's been moved to ToolConfiguration
        //      in order to fail as earlier as possible.  most people won't
        //      manually create ToolInfo.  if they do and we can't get an
        //      instance, they should be capable of figuring out what happened
    }

    /**
     * <p>Set the factory class used to create tool instances.</p>
     * <p>The factory is supposed to have one of those three methods:</p>
     * <ul>
     *     <li>create<i>ToolClassName</i>()</li>
     *     <li>new<i>ToolClassName</i>()</li>
     *     <li>get<i>ToolClassName</i>()</li>
     * </ul>
     * <p>where <i>ToolClassName</i> is the tool's class name.</p>
     * <p>If this method takes one <code>java.util.Map</code> argument, it will be given the tool's configuration map.</p>
     *
     * @param factory factory class
     */
    public void setFactory(Class factory)
    {
        this.factory = factory;
    }

    /**
     * @param path the full or partial request path restriction of the tool
     */
    public void restrictTo(String path)
    {
        if (path != null && !path.startsWith("/"))
        {
            path = "/" + path;
        }

        if (path == null || path.equals("*"))
        {
            // match all paths
            restrictToIsExact = false;
            this.restrictTo = null;
        }
        else if(path.endsWith("*"))
        {
            // match some paths
            restrictToIsExact = false;
            this.restrictTo = path.substring(0, path.length() - 1);
        }
        else
        {
            // match one path
            restrictToIsExact = true;
            this.restrictTo = path;
        }
    }

    /**
     * Set whether or not to skip setters.
     * @param cfgOnly flag value
     */
    public void setSkipSetters(boolean cfgOnly)
    {
        this.skipSetters = cfgOnly;
    }

    /**
     * Adds a map of properties from a parent scope to the properties
     * for this tool.  Only new properties will be added; any that
     * are already set for this tool will be ignored.
     * @param parentProps parent properties map
     */
    public void addProperties(Map<String,Object> parentProps)
    {
        // only add those new properties for which we
        // do not already have a value. first prop set wins.
        Map<String,Object> properties = getProps();
        for (Map.Entry<String,Object> prop : parentProps.entrySet())
        {
            if (!properties.containsKey(prop.getKey()))
            {
                properties.put(prop.getKey(), prop.getValue());
            }
        }
    }

    /**
     * Puts a new property for this tool.
     * @param name property name
     * @param value property value
     * @return previous property value
     */
    public Object putProperty(String name, Object value)
    {
        return getProps().put(name, value);
    }

    /**
     * Get tools property (synchronized version)
     * @return tools property
     */
    protected synchronized Map<String,Object> getProps()
    {
        if (properties == null)
        {
            properties = new HashMap<String,Object>();
        }
        return properties;
    }


    /***********************  Accessors *************************/

    /**
     * Get tool key
     * @return tool key
     */
    public String getKey()
    {
        return key;
    }

    /**
     * Get tool class name
     * @return tool class name
     */
    public String getClassname()
    {
        return clazz.getName();
    }

    /**
     * Get tool class
     * @return tool class
     */
    public Class getToolClass()
    {
        return clazz;
    }

    /**
     * Get factory class
     * @return factory class or null if not provided
     */
    public Class getFactory()
    {
        return factory;
    }

    /**
     * Get tool properties
     * @return tools properties
     */
    public Map<String,Object> getProperties()
    {
        return getProps();
    }

    /**
     * Get whether this tool has a <code>configure()</code> method
     * @return <code>true</code> if the tool has a <code>configure()</code> method, <code>false</code> otherwise
     */
    public boolean hasConfigure()
    {
        return (getConfigure() != null);
    }

    /**
     * Get whether setters are to be skipped
     * @return whether to skip setters
     */
    public boolean isSkipSetters()
    {
        if (skipSetters == null)
        {
            skipSetters = (clazz.getAnnotation(SkipSetters.class) != null);
        }
        return skipSetters;
    }

    /**
     * @param path the path of a template requesting this tool
     * @return <code>true</code> if the specified
     *         request path matches the restrictions of this tool.
     *         If there is no request path restriction for this tool,
     *         it will always return <code>true</code>.
     */
    public boolean hasPermission(String path)
    {
        if (this.restrictTo == null)
        {
            return true;
        }
        else if (restrictToIsExact)
        {
            return this.restrictTo.equals(path);
        }
        else if (path != null)
        {
            return path.startsWith(this.restrictTo);
        }
        return false;
    }


    /***********************  create() *************************/

    /**
     * Returns a new instance of the tool. If the tool
     * has an configure(Map) method, the new instance
     * will be initialized using the given properties combined with
     * whatever "constant" properties have been put into this
     * ToolInfo.
     * @param dynamicProperties map of dynamic properties
     * @return newly created and configured object
     */
    public Object create(Map<String,Object> dynamicProperties)
    {
        /* Get the tool instance */
        Object tool = newInstance();

        /* put configured props into the combo last, since
           dynamic properties will almost always be conventions
           and we need to let configuration win out */
        Map<String,Object> props;
        if (properties == null)
        {
            props = dynamicProperties;
        }
        else
        {
            props = combine(dynamicProperties, properties);
        }

        // perform the actual configuration of the new tool
        configure(tool, props);
        return tool;
    }


    /***********************  protected methods *************************/

    /**
     * Actually performs configuration of the newly instantiated tool
     * using the combined final set of configuration properties. First,
     * if the class lacks the {@link SkipSetters} annotation, then any
     * specific setters matching the configuration keys are called, then
     * the general configure(Map) method (if any) is called.
     * @param tool newly created tool to be configured
     * @param configuration properties
     */
    protected void configure(Object tool, Map<String,Object> configuration)
    {
        if (!isSkipSetters() && configuration != null)
        {
            try
            {
                // look for specific setters
                for (Map.Entry<String,Object> conf : configuration.entrySet())
                {
                    setProperty(tool, conf.getKey(), conf.getValue());
                }
            }
            catch (RuntimeException re)
            {
                throw re;
            }
            catch (Exception e)
            {
                // convert to a runtime exception, and re-throw
                throw new RuntimeException(e);
            }
        }

        if (hasConfigure())
        {
            invoke(getConfigure(), tool, configuration);
        }
    }

    /**
     * Try to find a <code>configure()</code> method.
     * @return <code>configure()</code> method if found, <code>null</code>otherwise.
     */
    protected Method getConfigure()
    {
        if (this.configure == null)
        {
            // search for a configure(Map params) method in the class
            try
            {
                this.configure = ClassUtils.findMethod(clazz, CONFIGURE_METHOD_NAME,
                                              new Class[]{ Map.class });
            }
            catch (SecurityException se)
            {
                // fail early, rather than wait until
                String msg = "Unable to gain access to '" +
                             CONFIGURE_METHOD_NAME + "(Map)'" +
                             " method for '" + clazz.getName() +
                             "' under the current security manager."+
                             "  This tool cannot be properly configured for use.";
                throw new IllegalStateException(msg, se);
            }
        }
        return this.configure;
    }

    /* TODO? if we have performance issues with copyProperties,
             look at possibly finding and caching these common setters
                setContext(VelocityContext)
                setVelocityEngine(VelocityEngine)
                setLog(Log)
                setLocale(Locale)
             these four are tricky since we may not want servlet deps here
                setRequest(ServletRequest)
                setSession(HttpSession)
                setResponse(ServletResponse)
                setServletContext(ServletContext)    */

    /**
     * Creates a new instance for this tool.
     * @return newly created tool
     * @throws IllegalStateException if creation failed
     */
    protected Object newInstance()
    {
        try
        {
            Class factory = getFactory();
            if (factory == null)
            {
                return clazz.newInstance();
            }
            else
            {
                Method factoryMethod = ClassUtils.findFactoryMethod(factory, clazz);
                return factoryMethod.invoke(null, new Object[] {});
            }
        }
        /* we shouldn't get either of these exceptions here because
         * we already got an instance of this class during setClass().
         * but to be safe, let's catch them and re-throw as RuntimeExceptions */
        catch (IllegalAccessException iae)
        {
            String message = "Unable to instantiate instance of \"" +
                  getClassname() + "\"";
            throw new IllegalStateException(message, iae);
        }
        catch (InstantiationException | InvocationTargetException e)
        {
            String message = "Exception while instantiating instance of \"" +
                  getClassname() + "\"";
            throw new IllegalStateException(message, e);
        }

    }

    /**
     * Invoke a single argument method on a tool
     * @param method the method to invoke
     * @param tool the tool on which to invoke the method
     * @param param the method argument
     * @throws IllegalStateException if invocation failed
     */
    protected void invoke(Method method, Object tool, Object param)
    {
        try
        {
            // call the setup method on the instance
            method.invoke(tool, new Object[]{ param });
        }
        catch (IllegalAccessException iae)
        {
            String msg = "Unable to invoke " + method + " on " + tool;
            // restricting access to this method by this class ist verboten
            throw new IllegalStateException(msg, iae);
        }
        catch (InvocationTargetException ite)
        {
            String msg = "Exception when invoking " + method + " on " + tool;
            // convert to a runtime exception, and re-throw
            throw new RuntimeException(msg, ite.getCause());
        }
    }

    /**
     * Set a property on a tool instance
     * @param tool tool instance
     * @param name property name
     * @param value property value
     * @throws Exception if setting the property throwed
     */
    protected void setProperty(Object tool, String name, Object value) throws Exception
    {
        if (PropertyUtils.isWriteable(tool, name))
        {
            //TODO? support property conversion here?
            //      heavy-handed way is BeanUtils.copyProperty(...)
            PropertyUtils.setProperty(tool, name, value);
        }
    }

    //TODO? move to Utils?
    /**
     * Combine several property maps
     * @param maps maps to combine
     * @return combined map
     */
    protected Map<String,Object> combine(Map<String,Object>... maps)
    {
        Map<String,Object> combined = new HashMap<String,Object>();
        for (Map<String,Object> map : maps)
        {
            if (map != null)
            {
                combined.putAll(map);
            }
        }
        return combined;
    }

}