// Copyright 2008-2012 severally by the contributors
//
// 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 net.sf.practicalxml.converter.bean;

import java.lang.reflect.Array;
import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.text.DateFormat;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Collection;
import java.util.Collections;
import java.util.Date;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.SortedMap;
import java.util.SortedSet;
import java.util.TimeZone;
import java.util.TreeMap;
import java.util.TreeSet;

import javax.xml.XMLConstants;

import org.w3c.dom.Attr;
import org.w3c.dom.Element;
import org.w3c.dom.Node;

import net.sf.kdgcommons.bean.IntrospectionCache;
import net.sf.kdgcommons.lang.ObjectUtil;
import net.sf.kdgcommons.lang.StringUtil;
import net.sf.practicalxml.DomUtil;
import net.sf.practicalxml.XmlUtil;
import net.sf.practicalxml.converter.ConversionException;
import net.sf.practicalxml.converter.ConversionConstants;
import net.sf.practicalxml.converter.internal.ConversionUtils;
import net.sf.practicalxml.converter.internal.JavaStringConversions;
import net.sf.practicalxml.converter.internal.TypeUtils;


/**
 *  Driver class for converting an XML DOM into a Java bean. Normal usage is
 *  to create a single instance of this class with desired options, then use
 *  it for multiple conversions. This class is thread-safe.
 *
 *  @since 1.1
 */
public class Xml2BeanConverter
{
    // these date formatters will be lazily instantiated
    private DateFormat _defaultDateFormat;
    private DateFormat _sqlDateFormat;
    private DateFormat _sqlTimeFormat;
    private DateFormat _sqlTimestampFormat;

    private EnumSet<Xml2BeanOptions> _options;
    private IntrospectionCache _introspections;
    private JavaStringConversions _converter;

    // lazily initialized if DEFER_EXCEPTIONS option set
    List<ConversionException> _deferredExceptions;


    public Xml2BeanConverter(Xml2BeanOptions... options)
    {
        _options = EnumSet.noneOf(Xml2BeanOptions.class);
        for (Xml2BeanOptions option : options)
        {
            _options.add(option);
            if (option == Xml2BeanOptions.DEFER_EXCEPTIONS)
                _deferredExceptions = new ArrayList<ConversionException>();
        }

        _introspections = new IntrospectionCache(_options.contains(Xml2BeanOptions.CACHE_INTROSPECTIONS));
        _converter = new JavaStringConversions(_options.contains(Xml2BeanOptions.EXPECT_XSD_FORMAT));
    }


//----------------------------------------------------------------------------
//  Public Methods
//----------------------------------------------------------------------------

    /**
     *  Attempts to convert the passed DOM subtree into an object of the
     *  specified class.
     */
    public <T> T convert(Element elem, Class<T> klass)
    {
        return klass.cast(convertWithoutCast(elem, klass));
    }


    /**
     *  If the deferred exceptions option has been set, returns the list of
     *  deferred exceptions. Returns an empty list if the option was not set,
     *  or if no exceptions were thrown.
     *  <p>
     *  The returned list is immutable.
     */
    public List<ConversionException> getDeferredExceptions()
    {
        if (_deferredExceptions != null)
            return Collections.unmodifiableList(_deferredExceptions);
        else
            return Collections.emptyList();
    }


//----------------------------------------------------------------------------
//  Internal Conversion Methods
//----------------------------------------------------------------------------

    /**
     *  Attempts to convert the passed DOM subtree into an object of the
     *  specified class. Note that this version does not use generics,
     *  and does not try to cast the result, whereas the public version
     *  does. Internally, we want to treat <code>Integer.TYPE</code> the
     *  same as <code>Integer.class</code>, and the cast prevents that.
     */
    public Object convertWithoutCast(Element elem, Class<?> klass)
    {
        validateXsiType(elem, klass);
        if (isAllowableNull(elem))
            return null;

        Object obj = tryConvertSimple(elem, klass);
        if (obj == null)
            obj = tryConvertAsEnum(elem, klass);
        if (obj == null)
            obj = tryConvertAsArray(elem, klass);
        if (obj == null)
            obj = tryConvertAsSimpleCollection(elem, klass);
        if (obj == null)
            obj = tryConvertAsMap(elem, klass);
        if (obj == null)
            obj = tryConvertAsDate(elem, klass);
        if (obj == null)
            obj = tryConvertAsCalendar(elem, klass);
        if (obj == null)
            obj = tryConvertAsBean(elem, klass);
        return obj;
    }


    private boolean isAllowableNull(Element elem)
    {
        String text = getText(elem, false);
        if ((text != null) || hasElementChildren(elem))
            return false;

        for (Attr attr : DomUtil.getAttributes(elem))
        {
            if (isConvertableAttribute(elem, attr))
                return false;
        }

        if (_options.contains(Xml2BeanOptions.REQUIRE_XSI_NIL) && !ConversionUtils.getXsiNil(elem))
        {
            ConversionException ex = new ConversionException("missing/false xsi:nil", elem);
            if (! exceptionDeferred(ex))
                throw ex;
        }

        return true;
    }


    private Object tryConvertSimple(Element elem, Class<?> klass)
    {
        if (!_converter .isConvertableToString(klass))
            return null;

        return _converter.parse(getText(elem, true), klass);
    }


    private Object tryConvertAsEnum(Element elem, Class<?> klass)
    {
        if (!Enum.class.isAssignableFrom(klass))
            return null;

        try
        {
            return new EnumParser(_options.contains(Xml2BeanOptions.ENUM_AS_STRING_VALUE)).parse(elem, klass);
        }
        catch (ConversionException ex)
        {
            if (! exceptionDeferred(ex))
                throw ex;

            return null;
        }
    }


    private Object tryConvertAsArray(Element elem, Class<?> klass)
    {
        Class<?> childKlass = klass.getComponentType();
        if (childKlass == null)
            return null;

        List<Element> children = DomUtil.getChildren(elem);
        Object result = Array.newInstance(childKlass, children.size());
        int idx = 0;
        for (Element child : children)
        {
            Array.set(result, idx++, convertWithoutCast(child, childKlass));
        }
        return result;
    }


    private Object tryConvertAsSimpleCollection(Element elem, Class<?> klass)
    {
        Collection<Object> result = instantiateCollection(klass);
        if (result == null)
            return null;

        List<Element> children = DomUtil.getChildren(elem);
        for (Element child : children)
        {
            try
            {
                Class<?> childClass = getCollectionElementClass(child);
                result.add(convertWithoutCast(child, childClass));
            }
            catch (ConversionException ex)
            {
                if (! exceptionDeferred(ex))
                    throw ex;
                // otherwise continue to next element
            }
        }
        return result;
    }


    private Object tryConvertAsMap(Element elem, Class<?> klass)
    {
        Map<Object,Object> result = instantiateMap(klass);
        if (result == null)
            return null;

        List<Element> children = DomUtil.getChildren(elem);
        for (Element child : children)
        {
            String key = ConversionUtils.getAttribute(child, ConversionConstants.AT_MAP_KEY);
            if (StringUtil.isEmpty(key))
                key = DomUtil.getLocalName(child);
            Class<?> childClass = getCollectionElementClass(child);
            result.put(key, convertWithoutCast(child, childClass));
        }
        return result;
    }


    private Object tryConvertAsDate(Element elem, Class<?> klass)
    {
        if (! Date.class.isAssignableFrom(klass))
            return null;

        String str = getText(elem,true);
        Date date = (_options.contains(Xml2BeanOptions.EXPECT_XSD_FORMAT))
                  ? XmlUtil.parseXsdDatetime(str)
                  : parseDateDefault(klass, str);

         return (klass == Date.class) ? date : instantiateDateSubclass(klass, date.getTime());
    }


    private Object tryConvertAsCalendar(Element elem, Class<?> klass)
    {
        if (! Calendar.class.isAssignableFrom(klass))
            return null;

        Long millis = null;
        TimeZone zone = null;
        int firstDayOfWeek = -1;
        int minDaysInFirstWeek = -1;
        for (Element child : DomUtil.getChildren(elem))
        {
            String childName = DomUtil.getLocalName(child);
            String childText = getText(child, true);
            if (childName.equals(ConversionConstants.EL_CALENDAR_MILLIS))
                millis = (Long)_converter.parse(childText, Long.class);
            else if (childName.equals(ConversionConstants.EL_CALENDAR_TIMEZONE))
                zone = (TimeZone)_converter.parse(childText, TimeZone.class);
            else if (childName.equals(ConversionConstants.EL_CALENDAR_FIRST_DAY))
                firstDayOfWeek = Integer.parseInt(childText);
            else if (childName.equals(ConversionConstants.EL_CALENDAR_MIN_DAYS))
                minDaysInFirstWeek = Integer.parseInt(childText);
        }

        Calendar cal = Calendar.getInstance(zone);
        cal.setTimeInMillis(millis.longValue());
        cal.setFirstDayOfWeek(firstDayOfWeek);
        cal.setMinimalDaysInFirstWeek(minDaysInFirstWeek);
        return cal;
    }


    private Object tryConvertAsBean(Element elem, Class<?> klass)
    {
        Object bean = instantiateBean(elem, klass);

        // the bean won't be null if we're deferring exceptions and we can't
        // instantiate; in that case, the entire subtree will be skipped
        if (bean != null)
        {
            convertAttributes(elem, bean);
            convertChildren(elem, bean);
        }

        return bean;
    }


//----------------------------------------------------------------------------
//  Other Internals
//----------------------------------------------------------------------------

    /**
     *  Returns the text content of an element, applying appropriate options,
     *  and with an optional validation that the element has no children.
     */
    private String getText(Element elem, boolean validateTextOnly)
    {
        if (validateTextOnly && hasElementChildren(elem))
        {
            ConversionException ex = new ConversionException("unexpected child elements", elem);
            if (! exceptionDeferred(ex))
                throw ex;
        }

        String text = DomUtil.getText(elem);
        if (StringUtil.isBlank(text) && _options.contains(Xml2BeanOptions.EMPTY_IS_NULL))
            text = null;
        return text;
    }


    private void validateXsiType(Element elem, Class<?> klass)
    {
        try
        {
            if (_options.contains(Xml2BeanOptions.REQUIRE_TYPE))
                TypeUtils.validateType(elem, klass);
        }
        catch (ConversionException ex)
        {
            if (! exceptionDeferred(ex))
                throw ex;
        }
    }


    private Class<?> getCollectionElementClass(Element child)
    {
        Class<?> childClass = TypeUtils.getType(child, false);
        return (childClass != null)
             ? childClass
             : String.class;
    }


    private boolean hasElementChildren(Element elem)
    {
        Node child = elem.getFirstChild();
        while (child != null)
        {
            if (child instanceof Element)
                return true;
            child = child.getNextSibling();
        }
        return false;
    }


    private boolean isConvertableAttribute(Element elem, Attr attr)
    {
        if (!_options.contains(Xml2BeanOptions.CONVERT_ATTRIBUTES)
                && !_options.contains(Xml2BeanOptions.CONVERT_ATTRIBUTES_MATCH_NAMESPACE))
            return false;

        String elemNS = elem.getNamespaceURI() != null ? elem.getNamespaceURI() : "";
        String attrNS = attr.getNamespaceURI() != null ? attr.getNamespaceURI() : "";

        if (_options.contains(Xml2BeanOptions.CONVERT_ATTRIBUTES_MATCH_NAMESPACE)
                && !elemNS.equals(attrNS))
        {
            return false;
        }

        if (attrNS.equals(XMLConstants.W3C_XML_SCHEMA_INSTANCE_NS_URI)
                || attrNS.equals(ConversionConstants.NS_CONVERSION))
        {
            return false;
        }

        return true;
    }


    private void convertAttributes(Element elem, Object bean)
    {
        Class<?> klass = bean.getClass();

        List<Attr> attrs = DomUtil.getAttributes(elem);
        for (Attr attr : attrs)
        {
            if (!isConvertableAttribute(elem, attr))
                continue;

            Method setter = getSetterMethod(klass, elem, DomUtil.getLocalName(attr));
            if (setter == null)
                continue;

            Class<?> attrClass = setter.getParameterTypes()[0];
            if (!_converter .isConvertableToString(attrClass))
                continue;

            Object attrValue = _converter.parse(attr.getValue(), attrClass);
            if (attrValue != null)
                invokeSetter(elem, bean, setter, attrValue);
        }
    }


    private void convertChildren(Element elem, Object bean)
    {
        Class<?> klass = bean.getClass();

        List<Element> children = DomUtil.getChildren(elem);
        for (Element child : children)
        {
            Method setter = getSetterMethod(klass, child, DomUtil.getLocalName(child));
            if (setter == null)
                continue;

            Class<?> childClass = setter.getParameterTypes()[0];
            Object childValue = convertWithoutCast(child, childClass);
            invokeSetter(elem, bean, setter, childValue);
        }
    }


    private Method getSetterMethod(Class<?> beanKlass, Element elem, String propName)
    {
        Method setter = _introspections.lookup(beanKlass).setter(propName);
        if ((setter == null) && !_options.contains(Xml2BeanOptions.IGNORE_MISSING_PROPERTIES))
        {
            ConversionException ex = new ConversionException("can't find property setter: " + propName, elem);
            if (! exceptionDeferred(ex))
                throw ex;
        }

        return setter;
    }


    /**
     *  Attempts to parse a date that's in it's default toString() format. Formats are lazily
     *  created (premature optimization, I know), following the JavaDoc for the appropriate
     *  class.
     */
    private synchronized Date parseDateDefault(Class<?> klass, String str)
    {
        try
        {
            if (java.sql.Date.class.isAssignableFrom(klass))
            {
                if (_sqlDateFormat == null)
                    _sqlDateFormat = new SimpleDateFormat("yyyy-MM-dd");
                return _sqlDateFormat.parse(str);
            }
            else if (java.sql.Time.class.isAssignableFrom(klass))
            {
                if (_sqlTimeFormat == null)
                    _sqlTimeFormat = new SimpleDateFormat("HH:mm:ss");
                return _sqlTimeFormat.parse(str);
            }
            else if (java.sql.Timestamp.class.isAssignableFrom(klass))
            {
                if (_sqlTimestampFormat == null)
                    _sqlTimestampFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
                return _sqlTimestampFormat.parse(str);
            }
            else
            {
                if (_defaultDateFormat == null)
                    _defaultDateFormat = new SimpleDateFormat("EEE MMM dd HH:mm:ss zzz yyyy");
                return _defaultDateFormat.parse(str);
            }
        }
        catch (ParseException ee)
        {
            throw new ConversionException("unable to parse: " + str, ee);
        }
    }


    /**
     *  Attempts to reflectively instantiate a subclass of <code>java.util.Date</code>.
     *  The class must expose a constructor that takes a <code>long</code> milliseconds
     *  value.
     */
    private Object instantiateDateSubclass(Class<?> klass, long millis)
    {
        try
        {
            Constructor<?> ctor = klass.getConstructor(Long.TYPE);
            return ctor.newInstance(Long.valueOf(millis));
        }
        catch (Exception ex)
        {
            throw new ConversionException("unable to instantiate: " + klass.getName(), ex);
        }
    }


    /**
     *  Attempts to create a <code>Collection</code> instance appropriate for
     *  the passed class, returns <code>null</code> if unable.
     */
    private Collection<Object> instantiateCollection(Class<?> klass)
    {
        if (SortedSet.class.isAssignableFrom(klass))
            return new TreeSet<Object>();
        else if (Set.class.isAssignableFrom(klass))
            return new HashSet<Object>();
        else if (List.class.isAssignableFrom(klass))
            return new ArrayList<Object>();
        else if (Collection.class.isAssignableFrom(klass))
            return new ArrayList<Object>();
        else
            return null;
    }


    /**
     *  Attempts to create a <code>Map</code> instance appropriate for the
     *  passed class, returns <code>null</code> if unable.
     */
    private Map<Object,Object> instantiateMap(Class<?> klass)
    {
        if (SortedMap.class.isAssignableFrom(klass))
            return new TreeMap<Object,Object>();
        else if (Map.class.isAssignableFrom(klass))
            return new HashMap<Object,Object>();
        else
            return null;
    }


    private Object instantiateBean(Element elem, Class<?> klass)
    {
        try
        {
            return klass.newInstance();
        }
        catch (Exception ee)
        {
            ConversionException ex = new ConversionException("unable to instantiate bean", elem, ee);
            if (! exceptionDeferred(ex))
                throw ex;
            else
                return null;
        }
    }


    private void invokeSetter(Element elem, Object bean, Method setter, Object value)
    {
        try
        {
            setter.invoke(bean, value);
        }
        catch (Exception ee)
        {
            ConversionException ex = new ConversionException("unable to invoke setter: " + setter.getName(), elem, ee);
            if (! exceptionDeferred(ex))
                throw ex;
        }
    }


    private boolean exceptionDeferred(ConversionException ex)
    {
        if (_deferredExceptions != null)
        {
            _deferredExceptions.add(ex);
            return true;
        }
        else
        {
            return false;
        }
    }


    /**
     *  A wrapper for the <code>Method</code> invocation used to parse enums.
     *  Exists to hide the various invocation exceptions, make the top-level
     *  flow easier to follow.
     */
    private static class EnumParser
    {
        private boolean _matchByStringValue;

        public EnumParser(boolean matchByStringValue)
        {
            _matchByStringValue = matchByStringValue;
        }

        public Object parse(Element elem, Class<?> klass)
        {
            String text = DomUtil.getText(elem);

            try
            {
                if (_matchByStringValue)
                    return matchStringValue(klass, text);
                else
                    return matchName(klass, text);
            }
            catch (ConversionException ee)
            {
                throw new ConversionException(ee.getMessage(), elem);
            }
            catch (InvocationTargetException ee)
            {
                throw new ConversionException("unable to parse enum " + klass.getName()
                                              + ": \"" + text + "\"",
                                              elem, ee.getTargetException());
            }
            catch (Exception ee)
            {
                throw new ConversionException("unable to parse enum " + klass.getName()
                                              + ": \"" + text + "\"",
                                              elem, ee);
            }
        }

        private Object matchStringValue(Class<?> klass, String text)
        throws Exception
        {
            Method method = klass.getMethod("values");
            Object[] values = (Object[])method.invoke(null);
            for (Object value : values)
            {
                if (ObjectUtil.equals(text, String.valueOf(value)))
                    return value;
            }
            throw new ConversionException("unable to parse enum " + klass.getName()
                                          + ": \"" + text + "\"");
        }

        private Object matchName(Class<?> klass, String text)
        throws Exception
        {
            Method method = klass.getMethod("valueOf", Class.class, String.class);
            return method.invoke(null, klass, text);
        }
    }
}
