// 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.Method;
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.List;
import java.util.Map;

import org.w3c.dom.Element;

import net.sf.kdgcommons.bean.Introspection;
import net.sf.kdgcommons.bean.IntrospectionCache;
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.bean.Bean2XmlAppenders.Appender;
import net.sf.practicalxml.converter.bean.Bean2XmlAppenders.BasicAppender;
import net.sf.practicalxml.converter.bean.Bean2XmlAppenders.DirectAppender;
import net.sf.practicalxml.converter.bean.Bean2XmlAppenders.IndexedAppender;
import net.sf.practicalxml.converter.bean.Bean2XmlAppenders.MapAppender;
import net.sf.practicalxml.converter.internal.ConversionUtils;
import net.sf.practicalxml.converter.internal.JavaStringConversions;


/**
 *  Driver class for converting a Java bean into an XML DOM. 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.
 *  <p>
 *  <em>Note:</em>
 *  this class is intended to convert simple data transfer beans, using those
 *  objects' public getters and setters.
 *
 *  @since 1.1
 */
public class Bean2XmlConverter
{
    private EnumSet<Bean2XmlOptions> _options;
    private IntrospectionCache _introspections;
    private JavaStringConversions _converter;
    List<ConversionException> _deferredExceptions;

    private boolean _setAccessible;

    public Bean2XmlConverter(Bean2XmlOptions... options)
    {
        _options = EnumSet.noneOf(Bean2XmlOptions.class);
        for (Bean2XmlOptions option : options)
            _options.add(option);

        _introspections = new IntrospectionCache(_options.contains(Bean2XmlOptions.CACHE_INTROSPECTIONS));
        _converter = new JavaStringConversions(shouldUseXsdFormatting());

        _setAccessible = _options.contains(Bean2XmlOptions.SET_ACCESSIBLE);
    }


//----------------------------------------------------------------------------
//  Public methods
//----------------------------------------------------------------------------

    /**
     *  Creates an XML DOM with the specified root element name, and fills it
     *  by introspecting the passed object.
     *  <p>
     *  Neither elements nor attributes in the resulting DOM are namespaced.
     */
    public Element convert(Object obj, String rootName)
    {
        return convert(obj, null, rootName);
    }


    /**
     *  Creates an XML DOM with the specified root element name and namespace
     *  URI, and fills it by introspecting the passed object.
     *  <p>
     *  All decendent elements will use the same namespace URI (and prefix, if
     *  provided) as the root. Attributes created by the converter use the
     *  "conversion" namespace, found in {@link ConversionConstants}.
     */
    public Element convert(Object obj, String nsUri, String rootName)
    {
        Element root = DomUtil.newDocument(nsUri, rootName);
        doNamespaceHack(root);
        convert(obj, rootName, new DirectAppender(_options, root, obj));
        return root;
    }


    /**
     *  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();
    }


//----------------------------------------------------------------------------
//  Internals
//----------------------------------------------------------------------------

    /**
     *  Introspects the passed object, and appends its contents to the output.
     */
    private void convert(Object obj, String name, Appender appender)
    {
        try
        {
            if (obj == null)
                convertAsNull(null, name, appender);
            else if (_converter.isConvertableToString(obj))
                convertSimple(obj, name, appender);
            else if (obj instanceof Enum<?>)
                convertAsEnum(obj, name, appender);
            else if (obj.getClass().isArray())
                convertAsArray(obj, name, appender);
            else if (obj instanceof Map)
                convertAsMap(obj, name, appender);
            else if (obj instanceof Collection)
                convertAsCollection(obj, name, appender);
            else if (obj instanceof Date)
                convertAsDate(obj, name, appender);
            else if (obj instanceof Calendar)
                convertAsCalendar(obj, name, appender);
            else
                convertAsBean(obj, name, appender);
        }
        catch (Exception ex)
        {
            ConversionException ex2 = (ex instanceof ConversionException)
                                    ? new ConversionException((ConversionException)ex, name)
                                    : new ConversionException("unable to convert", name, ex);
            if (! exceptionDeferred(ex2))
                throw ex2;
        }
    }


    private boolean exceptionDeferred(ConversionException ex)
    {
        if (! _options.contains(Bean2XmlOptions.DEFER_EXCEPTIONS))
            return false;

        if (_deferredExceptions == null)
            _deferredExceptions = new ArrayList<ConversionException>();

        _deferredExceptions.add(ex);
        return true;
    }


    private boolean shouldUseXsdFormatting()
    {
        return _options.contains(Bean2XmlOptions.XSD_FORMAT)
            || _options.contains(Bean2XmlOptions.USE_TYPE_ATTR);
    }


    /**
     *  Introduces namespaces at the root level, because the Xerces serializer
     *  does not attempt to promote namespace definitions above the element in
     *  which they first appear. This means that the same declaration may be
     *  repeated at multiple places throughout a tree.
     *  <p>
     *  Will only introduce namespaces appropriate to the options in effect
     *  (ie, if you don't enable <code>xsi:nil</code>, then there's no need
     *  to declare the XML Schema Instance namespace).
     */
    private void doNamespaceHack(Element root)
    {
        if (_options.contains(Bean2XmlOptions.NULL_AS_XSI_NIL))
        {
            ConversionUtils.setXsiNil(root, false);
        }

        // I think it's more clear to express the rules this way, rather than
        // as an if-condition with nested sub-conditions
        boolean addCnvNS = _options.contains(Bean2XmlOptions.USE_INDEX_ATTR);
        addCnvNS |= !_options.contains(Bean2XmlOptions.MAP_KEYS_AS_ELEMENT_NAME);
        addCnvNS &= _options.contains(Bean2XmlOptions.USE_TYPE_ATTR);
        if (addCnvNS)
        {
            ConversionUtils.setAttribute(root, ConversionConstants.AT_DUMMY, "");
        }
    }


    private void convertAsNull(Class<?> klass, String name, Appender appender)
    {
        appender.appendValue(name, klass, null);
    }


    private void convertSimple(Object obj, String name, Appender appender)
    {
        appender.appendValue(name, obj.getClass(), _converter.stringify(obj));
    }


    private void convertAsDate(Object obj, String name, Appender appender)
    {
        String value = _options.contains(Bean2XmlOptions.XSD_FORMAT)
                     ? XmlUtil.formatXsdDatetime((Date)obj)
                     : obj.toString();
        appender.appendValue(name, obj.getClass(), value);
    }


    private void convertAsEnum(Object obj, String name, Appender appender)
    {
        String enumName = ((Enum<?>)obj).name();
        if (_options.contains(Bean2XmlOptions.ENUM_AS_NAME_AND_VALUE))
        {
            Element elem = appender.appendValue(name, obj.getClass(), obj.toString());
            ConversionUtils.setAttribute(
                        elem,
                        ConversionConstants.AT_ENUM_NAME,
                        enumName);
        }
        else
        {
            appender.appendValue(name, obj.getClass(), enumName);
        }
    }


    private void convertAsArray(Object obj, String name, Appender appender)
    {
        String childName = determineChildNameForSequence(name);
        Appender childAppender = appender;
        if (!_options.contains(Bean2XmlOptions.SEQUENCE_AS_REPEATED_ELEMENTS))
        {
            Element parent = appender.appendContainer(name, obj.getClass());
            childAppender = new IndexedAppender(appender, parent, obj);
        }

        int length = Array.getLength(obj);
        for (int idx = 0 ; idx < length ; idx++)
        {
            Object value = Array.get(obj, idx);
            convert(value, childName, childAppender);
        }
    }


    private void convertAsMap(Object obj, String name, Appender appender)
    {
        Element parent = appender.appendContainer(name, obj.getClass());
        Appender childAppender = new MapAppender(appender, parent, obj);
        for (Map.Entry<?,?> entry : ((Map<?,?>)obj).entrySet())
        {
            convert(entry.getValue(), String.valueOf(entry.getKey()), childAppender);
        }
    }


    private void convertAsCollection(Object obj, String name, Appender appender)
    {
        String childName = determineChildNameForSequence(name);
        Appender childAppender = appender;
        if (!_options.contains(Bean2XmlOptions.SEQUENCE_AS_REPEATED_ELEMENTS))
        {
            Element parent = appender.appendContainer(name, obj.getClass());
            childAppender = new IndexedAppender(appender, parent, obj);
        }

        for (Object value : (Collection<?>)obj)
        {
            convert(value, childName, childAppender);
        }
    }

    private void convertAsCalendar(Object obj, String name, Appender appender)
    {
        Element parent = appender.appendContainer(name, obj.getClass());
        Appender childAppender = new BasicAppender(appender, parent, obj);

        Calendar cal = (Calendar)obj;
        convert(cal.getTime(), ConversionConstants.EL_CALENDAR_DATE, childAppender);
        convert(cal.getTimeZone(), ConversionConstants.EL_CALENDAR_TIMEZONE, childAppender);
        convert(cal.getTimeInMillis(), ConversionConstants.EL_CALENDAR_MILLIS, childAppender);
        // the Calendar API docs say the following fields are set from locale; since
        // we can't get that locale from an instance, we need to store them explicitly
        convert(Integer.valueOf(cal.getFirstDayOfWeek()), ConversionConstants.EL_CALENDAR_FIRST_DAY, childAppender);
        convert(Integer.valueOf(cal.getMinimalDaysInFirstWeek()), ConversionConstants.EL_CALENDAR_MIN_DAYS, childAppender);
    }


    private void convertAsBean(Object obj, String name, Appender appender)
    {
        Element parent = appender.appendContainer(name, obj.getClass());
        Appender childAppender = new BasicAppender(appender, parent, obj);
        Introspection ispec = _introspections.lookup(obj.getClass(), _setAccessible);
        for (String propName : ispec.propertyNames())
        {
            convertBeanProperty(obj, ispec, propName, childAppender);
        }
    }


    private void convertBeanProperty(Object bean, Introspection ispec, String propName, Appender appender)
    {
        Object value;
        try
        {
            Method getter = ispec.getter(propName);
            value = (getter != null)
                  ? getter.invoke(bean)
                  : null;

            if (value == null)
                convertAsNull(ispec.type(propName), propName, appender);
            else if (appender.shouldSkip(value))
                return;
            else
                convert(value, propName, appender);
        }
        catch (Exception ex)
        {

            ConversionException ex2 = (ex instanceof ConversionException)
                                    ? new ConversionException((ConversionException)ex, propName)
                                    : new ConversionException("unable to retrieve bean property", propName, ex);
            if (! exceptionDeferred(ex2))
                throw ex2;
        }
    }


    private String determineChildNameForSequence(String parentName)
    {
        if (StringUtil.isEmpty(parentName))
            return ConversionConstants.EL_COLLECTION_ITEM;

        if (_options.contains(Bean2XmlOptions.SEQUENCE_AS_REPEATED_ELEMENTS))
            return parentName;

        if (!_options.contains(Bean2XmlOptions.SEQUENCE_NAMED_BY_PARENT))
            return ConversionConstants.EL_COLLECTION_ITEM;

        if (parentName.endsWith("s") || parentName.endsWith("S"))
            return parentName.substring(0, parentName.length() - 1);

        return parentName;
    }
}
