package org.codehaus.xfire.aegis.type;

import java.beans.PropertyDescriptor;
import java.io.InputStream;
import java.lang.reflect.Method;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;

import javax.xml.namespace.QName;
import javax.xml.stream.XMLStreamException;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.codehaus.xfire.XFireRuntimeException;
import org.codehaus.xfire.aegis.type.basic.BeanType;
import org.codehaus.xfire.aegis.type.basic.XMLBeanTypeInfo;
import org.codehaus.xfire.util.ClassLoaderUtils;
import org.codehaus.yom.Document;
import org.codehaus.yom.Element;
import org.codehaus.yom.stax.StaxBuilder;
import org.codehaus.yom.xpath.YOMXPath;
import org.jaxen.JaxenException;
import org.jaxen.XPath;

/**
 * Deduce mapping information from an xml file.
 * The xml file should be in the same packages as the class, with the name <code>className.aegis.xml</code>.
 * For example, given the following service interface:
 * <p/>
 * <pre>
 * public Collection getResultsForValues(String id, Collection values); //method 1
 * public Collection getResultsForValues(int id, Collection values); //method 2
 * public String getResultForValue(String value); //method 3
 * </pre>
 * An example of the type xml is:
 * <pre>
 * &lt;mappings&gt;
 *  &lt;mapping&gt;
 *    &lt;method name="getResultsForValues"&gt;
 *      &lt;return-type componentType="com.acme.ResultBean" /&gt;
 *      &lt;!-- no need to specify index 0, since it's a String --&gt;
 *      &lt;parameter index="1" componentType="java.lang.String" /&gt;
 *    &lt;/method&gt;
 *  &lt;/mapping&gt;
 * &lt;/mappings&gt;
 * </pre>
 * <p/>
 * Note that for values which can be easily deduced (such as the String parameter, or the second service method)
 * no mapping need be specified in the xml descriptor, which is why no mapping is specified for method 3.
 * <p/>
 * However, if you have overloaded methods with different semantics, then you will need to specify enough
 * parameters to disambiguate the method and uniquely identify it. So in the example above, the mapping
 * specifies will apply to both method 1 and method 2, since the parameter at index 0 is not specified.
 *
 * @author Hani Suleiman
 *         Date: Jun 14, 2005
 *         Time: 7:47:56 PM
 */
public class XMLTypeCreator extends AbstractTypeCreator
{
    private static final Log log = LogFactory.getLog(XMLTypeCreator.class);
    //cache of classes to documents
    private Map documents = new HashMap();

    protected Document getDocument(Class clazz)
    {
        Document doc = (Document)documents.get(clazz.getName());
        if(doc != null)
        {
            return doc;
        }
        String path = '/' + clazz.getName().replace('.', '/') + ".aegis.xml";
        InputStream is = clazz.getResourceAsStream(path);
        if(is == null) return null;
        try
        {
            doc = new StaxBuilder().build(is);
            documents.put(clazz.getName(), doc);
            return doc;
        }
        catch(XMLStreamException e)
        {
            log.error("Error loading file " + path, e);
        }
        return null;
    }

    public Type createCollectionType(TypeClassInfo info)
    {
        return super.createCollectionType(info, (Class)info.getGenericType());
    }

    public TypeClassInfo createClassInfo(PropertyDescriptor pd)
    {
        Element mapping = findMapping(pd.getReadMethod().getDeclaringClass());
        if(mapping == null)
        {
            return nextCreator.createClassInfo(pd);
        }
        
        Element propertyEl = getMatch(mapping, "./property[@name='" + pd.getName() + "']");
        if(propertyEl == null) 
        {
            return nextCreator.createClassInfo(pd);
        }

        TypeClassInfo info = new TypeClassInfo();
        info.setTypeClass(pd.getReadMethod().getReturnType());
        setComponentType(info, propertyEl);
        setKeyType(info, propertyEl);
        info.setName(createQName(propertyEl, propertyEl.getAttributeValue("mappedName")));

        return info;
    }
    
    protected Element findMapping(Class clazz)
    {
        Document doc = getDocument(clazz);
        if(doc == null) return null;
        
        Element mapping = getMatch(doc, "/mappings/mapping[@uri='" + getTypeMapping().getEncodingStyleURI() + "']");
        if (mapping == null)
        {
            mapping = getMatch(doc, "/mappings/mapping");
        }
        
        return mapping;
    }

    public Type createDefaultType(TypeClassInfo info)
    {
        Element mapping = findMapping(info.getTypeClass());
        
        if (mapping != null)
        {
            XMLBeanTypeInfo btinfo = new XMLBeanTypeInfo(getTypeMapping(), 
                                                 info.getTypeClass(),
                                                 mapping);
            btinfo.setTypeMapping(getTypeMapping());
            
            BeanType type = new BeanType(btinfo);
            
            QName name = btinfo.getSchemaType();
            if (name == null) name = createQName(info.getTypeClass());
            
            type.setSchemaType(name);
            
            type.setTypeClass(info.getTypeClass());
            type.setTypeMapping(getTypeMapping());

            return type;
        }
        else
        {
            return nextCreator.createDefaultType(info);
        }
    }

    public TypeClassInfo createClassInfo(Method m, int index)
    {
        Element mapping = findMapping(m.getDeclaringClass());
        
        if(mapping == null) return nextCreator.createClassInfo(m, index);
        
        //find the elements that apply to the specified method
        TypeClassInfo info = new TypeClassInfo();
        if(index >= 0)
        {
            if(index >= m.getParameterTypes().length)
            {
                throw new XFireRuntimeException("Method " + m + " does not have a parameter at index " + index);
            }
            //we don't want nodes for which the specified index is not specified
            List nodes = getMatches(mapping, "./method[@name='" + m.getName() + "']/parameter[@index='" + index + "']/parent::*");
            if(nodes.size() == 0)
            {
                //no mapping for this method
                return nextCreator.createClassInfo(m, index);
            }
            //pick the best matching node
            Element bestMatch = getBestMatch(mapping, m, nodes);
            if(bestMatch == null)
            {
                //no mapping for this method
                return nextCreator.createClassInfo(m, index);
            }
            info.setTypeClass(m.getParameterTypes()[index]);
            //info.setAnnotations(m.getParameterAnnotations()[index]);
            Element parameter = getMatch(bestMatch, "parameter[@index='" + index + "']");
            
            setComponentType(info, parameter);
            setKeyType(info, parameter);
            
            info.setName(createQName(parameter, parameter.getAttributeValue("mappedName")));
        }
        else
        {
            List nodes = getMatches(mapping, "./method[@name='" + m.getName() + "']/return-type/parent::*");
            if(nodes.size() == 0) return nextCreator.createClassInfo(m, index);
            Element bestMatch = getBestMatch(mapping, m, nodes);
            if(bestMatch == null)
            {
                //no mapping for this method
                return nextCreator.createClassInfo(m, index);
            }
            info.setTypeClass(m.getReturnType());
            //info.setAnnotations(m.getAnnotations());
            Element rtElement = bestMatch.getFirstChildElement("return-type");
            String componentType = rtElement.getAttributeValue("componentType");
            if(componentType != null)
            {
                try
                {
                    info.setGenericType(ClassLoaderUtils.loadClass(componentType, getClass()));
                }
                catch(ClassNotFoundException e)
                {
                    log.error("Unable to load mapping class " + componentType);
                }
            }
            
            info.setName(createQName(rtElement, rtElement.getAttributeValue("mappedName")));
        }

        return info;
    }

    protected void setComponentType(TypeClassInfo info, Element parameter)
    {
        String componentType = parameter.getAttributeValue("componentType");
        if(componentType != null)
        {
            try
            {
                info.setGenericType(ClassLoaderUtils.loadClass(componentType, getClass()));
            }
            catch(ClassNotFoundException e)
            {
                log.error("Unable to load mapping class " + componentType);
            }
        }
    }

    protected void setKeyType(TypeClassInfo info, Element parameter)
    {
        String componentType = parameter.getAttributeValue("keyType");
        if(componentType != null)
        {
            try
            {
                info.setKeyType(ClassLoaderUtils.loadClass(componentType, getClass()));
            }
            catch(ClassNotFoundException e)
            {
                log.error("Unable to load mapping class " + componentType);
            }
        }
    }
    
    private Element getBestMatch(Element mapping, Method method, List availableNodes)
    {
        //first find all the matching method names
        List nodes = getMatches(mapping, "./method[@name='" + method.getName() + "']");
        //remove the ones that aren't in our acceptable set, if one is specified
        if(availableNodes != null)
        {
            nodes.retainAll(availableNodes);
        }
        //no name found, so no matches
        if(nodes.size() == 0) return null;
        //if the method has no params, then more than one mapping is pointless
        Class[] parameterTypes = method.getParameterTypes();
        if(parameterTypes.length == 0) return (Element)nodes.get(0);
        //here's the fun part.
        //we go through the method parameters, ruling out matches
        for(int i = 0; i < parameterTypes.length; i++)
        {
            Class parameterType = parameterTypes[i];
            for(Iterator iterator = nodes.iterator(); iterator.hasNext();)
            {
                Element element = (Element)iterator.next();
                //first we check if the parameter index is specified
                Element match = getMatch(element, "parameter[@index='" + i + "']");
                if(match != null)
                {
                    //we check if the type is specified and matches
                    if(match.getAttributeValue("type") != null)
                    {
                        //if it doesn't match, then we can definitely rule out this result
                        if(!match.getAttributeValue("type").equals(parameterType.getName()))
                        {
                            iterator.remove();
                        }
                    }
                }
            }
        }
        //if we have just one node left, then it has to be the best match
        if(nodes.size() == 1) return (Element)nodes.get(0);
        //all remaining definitions could apply, so we need to now pick the best one
        //the best one is the one with the most parameters specified
        Element bestCandidate = null;
        int highestSpecified = 0;
        for(Iterator iterator = nodes.iterator(); iterator.hasNext();)
        {
            Element element = (Element)iterator.next();
            int availableParameters = element.getChildElements("parameter").size();
            if(availableParameters > highestSpecified)
            {
                bestCandidate = element;
                highestSpecified = availableParameters;
            }
        }
        return bestCandidate;
    }

    private Element getMatch(Object doc, String xpath)
    {
        try
        {
            XPath path = new YOMXPath(xpath);
            return (Element)path.selectSingleNode(doc);
        }
        catch(JaxenException e)
        {
            throw new XFireRuntimeException("Error evaluating xpath " + xpath, e);
        }
    }

    private List getMatches(Object doc, String xpath)
    {
        try
        {
            XPath path = new YOMXPath(xpath);
            List result = path.selectNodes(doc);
            return result;
        }
        catch(JaxenException e)
        {
            throw new XFireRuntimeException("Error evaluating xpath " + xpath, e);
        }
    }

    /**
     * Creates a QName from a string, such as "ns:Element".
     */
    protected QName createQName(Element e, String value)
    {
        if (value == null || value.length() == 0) return null;
        
        int index = value.indexOf(":");
        
        if (index == -1)
        {
            return new QName(getTypeMapping().getEncodingStyleURI(), value);
        }
        
        String prefix = value.substring(0, index);
        String localName = value.substring(index+1);
        String ns = e.getNamespaceURI(prefix);
        
        if (ns == null || localName == null)
            throw new XFireRuntimeException("Invalid QName in mapping: " + value);
        
        return new QName(ns, localName, prefix);
    }
}
