import org.dom4j.Attribute;
import org.dom4j.Document;
import org.dom4j.Element;
import org.dom4j.io.OutputFormat;
import org.dom4j.io.SAXReader;
import org.dom4j.io.XMLWriter;

import org.apache.commons.configuration.BaseConfiguration;
import org.apache.log4j.Logger;

import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.OutputStream;
import java.net.URL;
import java.util.Iterator;
import java.util.List;

/**
 * Reads an XML configuration file.
 *
 * To retrieve the value of an attribute of an element, use X.Y.Z[@attribute].
 * The '@' symbol was chosen for consistency with XPath.
 *
 * Setting property values will <b>NOT</b> automatically persist changes
 * to disk, unless autoSave=true.
 *
 * @author <a href="mailto:kelvint@apache.org">Kelvin Tan</a>
 */
public class XMLConfiguration extends BaseConfiguration
{
    /**
     * Logger.
     */
    protected static Logger log = Logger.getLogger(XMLConfiguration.class);

    // for conformance with xpath
    private static final char ATT_MARKER = '@';
    private static final String ATT_START_MARKER = "[" + ATT_MARKER;

    /**
     * For consistence with properties files. Access nodes via "A.B.C".
     */
    private static final String NODE_DELIMITER = ".";

    private File file;
    private Document doc;

    /**
     * If true, modifications are immediately persisted.
     */
    private boolean autoSave = false;

    /**
     * Attempts to load the XML file as a resource from the classpath. The XML file
     * must be located somewhere in the classpath.
     * @param resource Name of the resource
     */
    public XMLConfiguration(String resource)
    {
        URL confURL = this.getClass().getClassLoader().getResource(resource);
        if (confURL == null)
            confURL = ClassLoader.getSystemResource(resource);
        this.file = new File(confURL.getFile());
        try
        {
            SAXReader reader = new SAXReader();
            doc = reader.read(confURL);
            initProperties(doc.getRootElement(), new StringBuffer());
        }
        catch (Exception e)
        {
            log.error("Error creating XML parser", e);
        }
    }

    /**
     * Attempts to load the XML file.
     * @param file File object representing the XML file.
     */
    public XMLConfiguration(File file)
    {
        try
        {
            SAXReader reader = new SAXReader();
            doc = reader.read(file);
            initProperties(doc.getRootElement(), new StringBuffer());
        }
        catch (Exception e)
        {
            log.error("Error creating XML parser", e);
        }
    }

    /**
     * Loads and initializes from the XML file.
     * @param element
     * @param hierarchy
     */
    private void initProperties(Element element, StringBuffer hierarchy)
    {
        for (Iterator it = element.elementIterator(); it.hasNext();)
        {
            StringBuffer subhierarchy = new StringBuffer(hierarchy.toString());
            Element child = (Element) it.next();
            String nodeName = child.getName();
            String nodeValue = child.getTextTrim();
            subhierarchy.append(nodeName);
            if (nodeValue.length() > 0)
                super.addProperty(subhierarchy.toString(), nodeValue);

            // add attributes as x.y{ATT_START_MARKER}att{ATT_END_MARKER}
            List attributes = child.attributes();
            for (int j = 0,k = attributes.size(); j < k; j++)
            {
                Attribute a = (Attribute) attributes.get(j);
                String attName = a.getName();
                String attValue = a.getValue();
                StringBuffer sb = new StringBuffer(subhierarchy.toString());
                sb.append('[');
                sb.append(ATT_MARKER);
                sb.append(attName);
                sb.append(']');
                super.addProperty(sb.toString(), attValue);
            }
            StringBuffer sb = new StringBuffer(subhierarchy.toString());
            sb.append('.');
            initProperties(child, sb);
        }
    }

    /**
     * Get a boolean associated with the given configuration key.
     * Somehow, I'd prefer to return false instead of throwing a
     * NoSuchElementException for getBoolean.
     *
     * @param key The configuration key.
     * @return The associated boolean, or false if the key doesn't exist.
     * @exception ClassCastException is thrown if the key maps to an
     * object that is not a Boolean.
     */
    public boolean getBoolean(String key)
    {
        Boolean b = getBoolean(key, null);
        if (b != null)
        {
            return b.booleanValue();
        }
        else
        {
            return false;
        }
    }

    /**
     * Calls super method, and also ensures the underlying {@link Document} is
     * modified so changes are persisted when saved.
     * @param name
     * @param value
     */
    public void addProperty(String name, Object value)
    {
        super.addProperty(name, value);
        setXmlProperty(name, value);
        if (autoSave) save();
    }

    /**
     * Calls super method, and also ensures the underlying {@link Document} is
     * modified so changes are persisted when saved.
     * @param name
     * @param value
     */
    public void setProperty(String name, Object value)
    {
        super.setProperty(name, value);
        setXmlProperty(name, value);
        if (autoSave) save();
    }

    private void setXmlProperty(String name, Object value)
    {
        String[] nodes = org.apache.commons.lang.StringUtils.split(name, NODE_DELIMITER);
        String attName = null;
        Element element = doc.getRootElement();
        for (int i = 0; i < nodes.length; i++)
        {
            String eName = nodes[i];
            int index = eName.indexOf(ATT_START_MARKER);
            if (index > -1)
            {
                attName = eName.substring(index + ATT_START_MARKER.length(),
                                          eName.length() - 1);
                eName = eName.substring(0, index);
            }
            // If we don't find this part of the property in the XML heirarchy
            // we add it as a new node
            if (element.element(eName) == null && attName == null)
            {
                element.addElement(eName);
            }
            element = element.element(eName);
        }
        if (attName == null)
        {
            element.setText((String) value);
        }
        else
        {
            element.addAttribute(attName, (String) value);
        }
    }

    /**
     * Calls super method, and also ensures the underlying {@link Document} is
     * modified so changes are persisted when saved.
     * @param name
     */
    public void clearProperty(String name)
    {
        super.clearProperty(name);
        clearXmlProperty(name);
        if (autoSave) save();
    }

    private void clearXmlProperty(String name)
    {
        String[] nodes = org.apache.commons.lang.StringUtils.split(name, NODE_DELIMITER);
        String attName = null;
        Element element = doc.getRootElement();
        for (int i = 0; i < nodes.length; i++)
        {
            String eName = nodes[i];
            int index = eName.indexOf(ATT_START_MARKER);
            if (index > -1)
            {
                attName = eName.substring(index + ATT_START_MARKER.length(),
                                          eName.length() - 1);
                eName = eName.substring(0, index);
            }
            element = element.element(eName);
            if (element == null)
            {
                if (log.isDebugEnabled())
                {
                    log.debug("Key:" + name + " was requested but no such "
                              + " element exists.");
                }
                return;
            }
        }
        if (attName == null)
        {
            element.remove(element.element(nodes[nodes.length - 1]));
        }
        else
        {
            element.remove(element.attribute(attName));
        }
    }

    /**
     * If true, changes are automatically persisted.
     * @param autoSave
     */
    public void setAutoSave(boolean autoSave)
    {
        this.autoSave = autoSave;
    }

    public synchronized void save()
    {
        XMLWriter writer = null;
        OutputStream out = null;
        try
        {
            OutputFormat outputter = OutputFormat.createPrettyPrint();
            out = new BufferedOutputStream(new FileOutputStream(file));
            writer = new XMLWriter(out, outputter);
            writer.write(doc);
        }
        catch (Exception e)
        {
            log.error("Exception occured saving", e);
        }
        finally
        {
            try
            {
                if (writer != null) writer.close();
                if (out != null) out.close();
            }
            catch (Exception e)
            {
                log.error("Exception occured saving", e);
            }
        }
    }
}
