/*
 *  Copyright 2010, 2011 Christopher Pheby
 *
 *  Licensed 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.jadira.bindings.core.loader;

import java.io.IOException;
import java.io.InputStream;
import java.lang.annotation.Annotation;
import java.lang.reflect.Constructor;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.net.URL;
import java.net.URLConnection;
import java.util.ArrayList;
import java.util.List;

import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;

import org.jadira.bindings.core.annotation.BindingScope;
import org.jadira.bindings.core.annotation.DefaultBinding;
import org.jadira.bindings.core.spi.ConverterProvider;
import org.jadira.bindings.core.utils.lang.IterableNodeList;
import org.jadira.bindings.core.utils.reflection.ClassLoaderUtils;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.xml.sax.ErrorHandler;
import org.xml.sax.InputSource;
import org.xml.sax.SAXException;
import org.xml.sax.SAXParseException;

/**
 * A class capable of reading configuration from a given URL and producing the
 * resultant {@link BindingConfiguration} representation
 */
public final class BindingXmlLoader {

    private static final String BINDINGS_NAMESPACE = "http://org.jadira.bindings/xml/ns/binding";

	private BindingXmlLoader() {
    }

    /**
     * Given a configuration URL, produce the corresponding configuration
     * @param location The URL
     * @return The relevant {@link BindingConfiguration}
     * @throws IllegalStateException If the configuration cannot be parsed
     */
    public static BindingConfiguration load(URL location) throws IllegalStateException {
    	
        Document doc;
        try {
            doc = loadDocument(location);
        } catch (IOException e) {
            throw new IllegalStateException("Cannot load " + location.toExternalForm(), e);
        } catch (ParserConfigurationException e) {
            throw new IllegalStateException("Cannot initialise parser for " + location.toExternalForm(), e);
        } catch (SAXException e) {
            throw new IllegalStateException("Cannot parse " + location.toExternalForm(), e);
        }
        BindingConfiguration configuration = parseDocument(doc);
        return configuration;
    }

    /**
     * Helper method to load a DOM Document from the given configuration URL
     * @param location The configuration URL
     * @return A W3C DOM Document
     * @throws IOException If the configuration cannot be read
     * @throws ParserConfigurationException If the DOM Parser cannot be initialised
     * @throws SAXException If the configuraiton cannot be parsed
     */
    private static Document loadDocument(URL location) throws IOException, ParserConfigurationException, SAXException {

        InputStream inputStream = null;

        if (location != null) {
            URLConnection urlConnection = location.openConnection();
            urlConnection.setUseCaches(false);
            inputStream = urlConnection.getInputStream();
        }
        if (inputStream == null) {
        	if (location == null) {
        		throw new IOException("Failed to obtain InputStream for named location: null");
        	} else {
        		throw new IOException("Failed to obtain InputStream for named location: " + location.toExternalForm());
        	}
        }

        InputSource inputSource = new InputSource(inputStream);

        List<SAXParseException> errors = new ArrayList<SAXParseException>();
        DocumentBuilder docBuilder = constructDocumentBuilder(errors);

        Document document = docBuilder.parse(inputSource);
        if (!errors.isEmpty()) {
        	if (location == null) {
        		throw new IllegalStateException("Invalid File: null", (Throwable) errors.get(0));
        	} else {
        		throw new IllegalStateException("Invalid file: " + location.toExternalForm(), (Throwable) errors.get(0));
        	}
        }
        return document;
    }

    /**
     * Helper used to construct a document builder
     * @param errors A list for holding any errors that take place
     * @return JAXP {@link DocumentBuilder}
     * @throws ParserConfigurationException If the parser cannot be initialised
     */
    private static DocumentBuilder constructDocumentBuilder(List<SAXParseException> errors)
            throws ParserConfigurationException {

        DocumentBuilderFactory documentBuilderFactory = constructDocumentBuilderFactory();
        DocumentBuilder docBuilder = documentBuilderFactory.newDocumentBuilder();
        docBuilder.setEntityResolver(new BindingXmlEntityResolver());
        docBuilder.setErrorHandler(new BindingXmlErrorHandler(errors));
        return docBuilder;
    }

    /**
     * Helper used to construct a {@link DocumentBuilderFactory} with schema validation configured
     * @return {@link DocumentBuilderFactory}
     */
    private static DocumentBuilderFactory constructDocumentBuilderFactory() {

        DocumentBuilderFactory documentBuilderFactory = DocumentBuilderFactory.newInstance();

        documentBuilderFactory.setValidating(true);
        documentBuilderFactory.setNamespaceAware(true);

        try {
            documentBuilderFactory.setAttribute("http://apache.org/xml/features/validation/schema", true);
        } catch (IllegalArgumentException e) {
            // Ignore
        }
        documentBuilderFactory.setAttribute("http://java.sun.com/xml/jaxp/properties/schemaLanguage",
                "http://www.w3.org/2001/XMLSchema");
        documentBuilderFactory.setAttribute("http://java.sun.com/xml/jaxp/properties/schemaSource",
                "classpath:/jadira-bindings.xsd");
        return documentBuilderFactory;
    }

    /**
     * Walk the parsed {@link Document} and produce a {@link BindingConfiguration}
     * @param doc Document being Parsed
     * @return The resultant {@link BindingConfiguration}
     */
    private static BindingConfiguration parseDocument(Document doc) {

        BindingConfiguration result = new BindingConfiguration();

        Element docRoot = doc.getDocumentElement();
        for (Node next : new IterableNodeList(docRoot.getChildNodes())) {
            if (Node.ELEMENT_NODE == next.getNodeType()) {
                Element element = (Element) next;

                if (BINDINGS_NAMESPACE.equals(element.getNamespaceURI())
                        && "provider".equals(element.getLocalName())) {

                    Provider provider = parseProviderElement(element);
                    result.addProvider(provider);
                }

                if (BINDINGS_NAMESPACE.equals(element.getNamespaceURI())
                        && "extension".equals(element.getLocalName())) {

                    Extension<?> extension = parseBinderExtensionElement(element);
                    result.addExtension(extension);
                }
                
                if (BINDINGS_NAMESPACE.equals(element.getNamespaceURI())
                        && "binding".equals(element.getLocalName())) {

                    BindingConfigurationEntry binding = parseBindingConfigurationEntryElement(element);
                    result.addBindingEntry(binding);
                }
            }
        }
        return result;
    }

    /**
     * Parse the 'provider' element and its children
     * @param element The element
     * @return A {@link Provider} instance for the element
     */
    private static Provider parseProviderElement(Element element) {

        Class<?> providerClass = lookupClass(element.getAttribute("class"));

        if (providerClass == null) {
            throw new IllegalStateException("Referenced class {" + element.getAttribute("class")
                    + "} could not be found");
        }
        if (!ConverterProvider.class.isAssignableFrom(providerClass)) {
            throw new IllegalStateException("Referenced class {" + element.getAttribute("class")
                    + "} did not implement BindingProvider");
        }

        @SuppressWarnings("unchecked")
        final Class<ConverterProvider> typedProviderClass = (Class<ConverterProvider>) providerClass;
        return new Provider((Class<ConverterProvider>) typedProviderClass);
    }

    /**
     * Parse the 'extension' element
     * @param element The element
     * @return A {@link Extension} instance for the element
     */
    private static <T> Extension<T> parseBinderExtensionElement(Element element) {

        @SuppressWarnings("unchecked")
		Class<T> providerClass = (Class<T>)lookupClass(element.getAttribute("class"));
        
        Class<?> implementationClass = lookupClass(element.getAttribute("implementationClass"));

        if (providerClass == null) {
            throw new IllegalStateException("Referenced class {" + element.getAttribute("class")
                    + "} could not be found");
        }
        if (implementationClass == null) {
            throw new IllegalStateException("Referenced implementation class {" + element.getAttribute("implementationClass")
                    + "} could not be found");
        }
        if (providerClass.isAssignableFrom(implementationClass)) {
            throw new IllegalStateException("Referenced class {" + element.getAttribute("class")
                    + "} did not implement BindingProvider");
        }

		try {
			@SuppressWarnings("unchecked")
			final Class<? extends T> myImplementationClass = (Class<T>) implementationClass.newInstance();
			return new Extension<T>(providerClass, myImplementationClass);
		} catch (InstantiationException e) {
            throw new IllegalStateException("Referenced implementation class {" + element.getAttribute("implementationClass")
                    + "} could not be instantiated");
		} catch (IllegalAccessException e) {
            throw new IllegalStateException("Referenced implementation class {" + element.getAttribute("implementationClass")
                    + "} could not be accessed");
		}
    }
    
    /**
     * Parse the {@link BindingConfigurationEntry} element
     * @param element The element
     * @return A {@link BindingConfigurationEntry} element
     */
    @SuppressWarnings("unchecked")
	private static BindingConfigurationEntry parseBindingConfigurationEntryElement(Element element) {

    	Class<?> bindingClass = null;
    	Class<?> sourceClass = null;
    	Class<?> targetClass = null;
        Method toMethod = null;
        Method fromMethod = null;
        Constructor<?> fromConstructor = null;
        Class<? extends Annotation> qualifier = DefaultBinding.class;
        
        if (element.getAttribute("class").length() > 0) {
        	bindingClass = lookupClass(element.getAttribute("class"));
        }
        
        if (element.getAttribute("sourceClass").length() > 0) {
            sourceClass = lookupClass(element.getAttribute("sourceClass"));
        }
        if (element.getAttribute("targetClass").length() > 0) {
        	targetClass = lookupClass(element.getAttribute("targetClass"));
        }

        if (element.getAttribute("qualifier").length() > 0) {
        	
            qualifier = (Class<? extends Annotation>) lookupClass(element.getAttribute("qualifier"));

            if (qualifier.getAnnotation(BindingScope.class) == null) {
                    throw new IllegalStateException("Qualifier class {" + element.getAttribute("qualifier")
                            + "} was not marked as BindingScope");
            }
        }

        if (bindingClass != null) {
	        for (Node next : new IterableNodeList(element.getChildNodes())) {
	            if (Node.ELEMENT_NODE == next.getNodeType()) {
	                Element childElement = (Element) next;
	                if (BINDINGS_NAMESPACE.equals(element.getNamespaceURI())
	                        && "toMethod".equals(element.getLocalName())) {
	
	                    String toMethodName = childElement.getTextContent();
	
	                    try {
	                        toMethod = bindingClass.getMethod(toMethodName, new Class[] { targetClass });
	                    } catch (SecurityException e) {
	                    } catch (NoSuchMethodException e) {
	                    }
	                    if (toMethod != null && (!String.class.equals(toMethod.getReturnType())
	                            || !Modifier.isStatic(toMethod.getModifiers()))) {
	                        toMethod = null;
	                    }
	                    if (toMethod == null && bindingClass.equals(targetClass)) {
	                        try {
	                            toMethod = bindingClass.getMethod(toMethodName, new Class[] {});
	                        } catch (SecurityException e) {
	                        } catch (NoSuchMethodException e) {
	                        }
	                        if (toMethod != null && Modifier.isStatic(toMethod.getModifiers())) {
	                            toMethod = null;
	                        }
	                    }
	
	                } else if (BINDINGS_NAMESPACE.equals(element.getNamespaceURI())
	                        && "fromMethod".equals(element.getLocalName())) {
	
	                    String fromMethodName = childElement.getTextContent();
	
	                    try {
	                        fromMethod = bindingClass.getMethod(fromMethodName, new Class[] { String.class });
	                    } catch (SecurityException e) {
	                    } catch (NoSuchMethodException e) {
	                    }
	                    if (fromMethod != null && ((targetClass != null && !targetClass.isAssignableFrom(fromMethod.getReturnType()))
	                    	|| !Modifier.isStatic(fromMethod.getModifiers()))) {
	                    	fromMethod = null;
	                    }
	
	                } else if (BINDINGS_NAMESPACE.equals(element.getNamespaceURI())
	                        && "fromConstructor".equals(element.getLocalName())) {
	
	                    try {
	                        fromConstructor = bindingClass.getConstructor(new Class[] { String.class });
	                    } catch (SecurityException e) {
	                    } catch (NoSuchMethodException e) {
	                    }
	                }
	            }
	        }
        }

        if (bindingClass == null) {

        	if (sourceClass == null) {
        		throw new IllegalStateException("If bindingClass is not populated, sourceClass must be present");
        	}
        	if (targetClass == null) {
        		throw new IllegalStateException("If bindingClass is not populated, targetClass must be present");
        	}
        	if (fromMethod != null && fromConstructor != null) {
        		throw new IllegalStateException("If fromMethod is populated, fromConstructor must not be present");
        	}

        	if (fromMethod == null) {
        	
        		return new BindingConfigurationEntry(sourceClass, targetClass, qualifier, toMethod, fromConstructor);
        	} else {
        		
        		return new BindingConfigurationEntry(sourceClass, targetClass, qualifier, toMethod, fromMethod);
        	}
        } else {
        	if (sourceClass != null) {
        		throw new IllegalStateException("If bindingClass is populated, sourceClass must not be present");
        	}
        	if (targetClass != null) {
        		throw new IllegalStateException("If bindingClass is populated, targetClass must not be present");
        	}
        	if (toMethod != null) {
        		throw new IllegalStateException("If bindingClass is populated, toMethod must not be present");
        	}
        	if (fromMethod != null) {
        		throw new IllegalStateException("If bindingClass is populated, fromMethod must not be present");
        	}
        	if (fromConstructor != null) {
        		throw new IllegalStateException("If bindingClass is populated, fromConstructor must not be present");
        	}
        	
        	return new BindingConfigurationEntry(bindingClass, qualifier);
        }
    }

    /**
     * Helper method that given a class-name will create the appropriate Class instance
     * @param elementName The class name
     * @return Instance of Class
     */
    private static Class<?> lookupClass(String elementName) {

        Class<?> clazz = null;
        try {
            clazz = ClassLoaderUtils.getClassLoader().loadClass(elementName);
        } catch (ClassNotFoundException e) {
            return null;
        }

        return clazz;
    }

    /**
     * SAX {@link ErrorHandler} that collects errors
     */
    private static class BindingXmlErrorHandler implements ErrorHandler {

        private List<SAXParseException> errors;

        /**
         * Create a new instance with the given error list for collecting errors
         * @param errors Error list to use
         */
        BindingXmlErrorHandler(List<SAXParseException> errors) {
            this.errors = errors;
        }

        /**
         * {@inheritDoc}
         */
        /* @Override */
        public void error(SAXParseException error) {
            errors.add(error);
        }

        /**
         * {@inheritDoc}
         */
        /* @Override */
        public void fatalError(SAXParseException error) {
            errors.add(error);
        }

        /**
         * {@inheritDoc}
         */
        /* @Override */
        public void warning(SAXParseException warn) {
            // ignore
        }
    }
}
