/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You 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.apache.commons.configuration2.plist;

import java.io.PrintWriter;
import java.io.Reader;
import java.io.UnsupportedEncodingException;
import java.io.Writer;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.text.DateFormat;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Calendar;
import java.util.Collection;
import java.util.Date;
import java.util.HashMap;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.TimeZone;

import javax.xml.parsers.SAXParser;
import javax.xml.parsers.SAXParserFactory;

import org.apache.commons.codec.binary.Base64;
import org.apache.commons.configuration2.BaseHierarchicalConfiguration;
import org.apache.commons.configuration2.FileBasedConfiguration;
import org.apache.commons.configuration2.HierarchicalConfiguration;
import org.apache.commons.configuration2.ImmutableConfiguration;
import org.apache.commons.configuration2.MapConfiguration;
import org.apache.commons.configuration2.ex.ConfigurationException;
import org.apache.commons.configuration2.ex.ConfigurationRuntimeException;
import org.apache.commons.configuration2.io.FileLocator;
import org.apache.commons.configuration2.io.FileLocatorAware;
import org.apache.commons.configuration2.tree.ImmutableNode;
import org.apache.commons.configuration2.tree.InMemoryNodeModel;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.text.StringEscapeUtils;
import org.xml.sax.Attributes;
import org.xml.sax.EntityResolver;
import org.xml.sax.InputSource;
import org.xml.sax.SAXException;
import org.xml.sax.helpers.DefaultHandler;

/**
 * Property list file (plist) in XML FORMAT as used by Mac OS X (http://www.apple.com/DTDs/PropertyList-1.0.dtd).
 * This configuration doesn't support the binary FORMAT used in OS X 10.4.
 *
 * <p>Example:</p>
 * <pre>
 * &lt;?xml version="1.0"?&gt;
 * &lt;!DOCTYPE plist SYSTEM "file://localhost/System/Library/DTDs/PropertyList.dtd"&gt;
 * &lt;plist version="1.0"&gt;
 *     &lt;dict&gt;
 *         &lt;key&gt;string&lt;/key&gt;
 *         &lt;string&gt;value1&lt;/string&gt;
 *
 *         &lt;key&gt;integer&lt;/key&gt;
 *         &lt;integer&gt;12345&lt;/integer&gt;
 *
 *         &lt;key&gt;real&lt;/key&gt;
 *         &lt;real&gt;-123.45E-1&lt;/real&gt;
 *
 *         &lt;key&gt;boolean&lt;/key&gt;
 *         &lt;true/&gt;
 *
 *         &lt;key&gt;date&lt;/key&gt;
 *         &lt;date&gt;2005-01-01T12:00:00Z&lt;/date&gt;
 *
 *         &lt;key&gt;data&lt;/key&gt;
 *         &lt;data&gt;RHJhY28gRG9ybWllbnMgTnVucXVhbSBUaXRpbGxhbmR1cw==&lt;/data&gt;
 *
 *         &lt;key&gt;array&lt;/key&gt;
 *         &lt;array&gt;
 *             &lt;string&gt;value1&lt;/string&gt;
 *             &lt;string&gt;value2&lt;/string&gt;
 *             &lt;string&gt;value3&lt;/string&gt;
 *         &lt;/array&gt;
 *
 *         &lt;key&gt;dictionnary&lt;/key&gt;
 *         &lt;dict&gt;
 *             &lt;key&gt;key1&lt;/key&gt;
 *             &lt;string&gt;value1&lt;/string&gt;
 *             &lt;key&gt;key2&lt;/key&gt;
 *             &lt;string&gt;value2&lt;/string&gt;
 *             &lt;key&gt;key3&lt;/key&gt;
 *             &lt;string&gt;value3&lt;/string&gt;
 *         &lt;/dict&gt;
 *
 *         &lt;key&gt;nested&lt;/key&gt;
 *         &lt;dict&gt;
 *             &lt;key&gt;node1&lt;/key&gt;
 *             &lt;dict&gt;
 *                 &lt;key&gt;node2&lt;/key&gt;
 *                 &lt;dict&gt;
 *                     &lt;key&gt;node3&lt;/key&gt;
 *                     &lt;string&gt;value&lt;/string&gt;
 *                 &lt;/dict&gt;
 *             &lt;/dict&gt;
 *         &lt;/dict&gt;
 *
 *     &lt;/dict&gt;
 * &lt;/plist&gt;
 * </pre>
 *
 * @since 1.2
 *
 */
public class XMLPropertyListConfiguration extends BaseHierarchicalConfiguration
    implements FileBasedConfiguration, FileLocatorAware
{
    /** Size of the indentation for the generated file. */
    private static final int INDENT_SIZE = 4;

    /** Constant for the encoding for binary data. */
    private static final String DATA_ENCODING = "UTF-8";

    /** Temporarily stores the current file location. */
    private FileLocator locator;

    /**
     * Creates an empty XMLPropertyListConfiguration object which can be
     * used to synthesize a new plist file by adding values and
     * then saving().
     */
    public XMLPropertyListConfiguration()
    {
    }

    /**
     * Creates a new instance of {@code XMLPropertyListConfiguration} and
     * copies the content of the specified configuration into this object.
     *
     * @param configuration the configuration to copy
     * @since 1.4
     */
    public XMLPropertyListConfiguration(final HierarchicalConfiguration<ImmutableNode> configuration)
    {
        super(configuration);
    }

    /**
     * Creates a new instance of {@code XMLPropertyConfiguration} with the given
     * root node.
     *
     * @param root the root node
     */
    XMLPropertyListConfiguration(final ImmutableNode root)
    {
        super(new InMemoryNodeModel(root));
    }

    private void setPropertyDirect(final String key, final Object value) {
        setDetailEvents(false);
        try
        {
            clearProperty(key);
            addPropertyDirect(key, value);
        }
        finally
        {
            setDetailEvents(true);
        }
    }

    @Override
    protected void setPropertyInternal(final String key, final Object value)
    {
        // special case for byte arrays, they must be stored as is in the configuration
        if (value instanceof byte[] || value instanceof List)
        {
            setPropertyDirect(key, value);
        }
        else if (value instanceof Object[])
        {
            setPropertyDirect(key, Arrays.asList((Object[]) value));
        }
        else
        {
            super.setPropertyInternal(key, value);
        }
    }

    @Override
    protected void addPropertyInternal(final String key, final Object value)
    {
        if (value instanceof byte[] || value instanceof List)
        {
            addPropertyDirect(key, value);
        }
        else if (value instanceof Object[])
        {
            addPropertyDirect(key, Arrays.asList((Object[]) value));
        }
        else
        {
            super.addPropertyInternal(key, value);
        }
    }

    /**
     * Stores the current file locator. This method is called before I/O
     * operations.
     *
     * @param locator the current {@code FileLocator}
     */
    @Override
    public void initFileLocator(final FileLocator locator)
    {
        this.locator = locator;
    }

    @Override
    public void read(final Reader in) throws ConfigurationException
    {
        // set up the DTD validation
        final EntityResolver resolver = (publicId, systemId) -> new InputSource(getClass().getClassLoader()
                .getResourceAsStream("PropertyList-1.0.dtd"));

        // parse the file
        final XMLPropertyListHandler handler = new XMLPropertyListHandler();
        try
        {
            final SAXParserFactory factory = SAXParserFactory.newInstance();
            factory.setValidating(true);

            final SAXParser parser = factory.newSAXParser();
            parser.getXMLReader().setEntityResolver(resolver);
            parser.getXMLReader().setContentHandler(handler);
            parser.getXMLReader().parse(new InputSource(in));

            getNodeModel().mergeRoot(handler.getResultBuilder().createNode(),
                    null, null, null, this);
        }
        catch (final Exception e)
        {
            throw new ConfigurationException(
                    "Unable to parse the configuration file", e);
        }
    }

    @Override
    public void write(final Writer out) throws ConfigurationException
    {
        if (locator == null)
        {
            throw new ConfigurationException("Save operation not properly "
                    + "initialized! Do not call write(Writer) directly,"
                    + " but use a FileHandler to save a configuration.");
        }
        final PrintWriter writer = new PrintWriter(out);

        if (locator.getEncoding() != null)
        {
            writer.println("<?xml version=\"1.0\" encoding=\"" + locator.getEncoding() + "\"?>");
        }
        else
        {
            writer.println("<?xml version=\"1.0\"?>");
        }

        writer.println("<!DOCTYPE plist SYSTEM \"file://localhost/System/Library/DTDs/PropertyList.dtd\">");
        writer.println("<plist version=\"1.0\">");

        printNode(writer, 1, getNodeModel().getNodeHandler().getRootNode());

        writer.println("</plist>");
        writer.flush();
    }

    /**
     * Append a node to the writer, indented according to a specific level.
     */
    private void printNode(final PrintWriter out, final int indentLevel, final ImmutableNode node)
    {
        final String padding = StringUtils.repeat(" ", indentLevel * INDENT_SIZE);

        if (node.getNodeName() != null)
        {
            out.println(padding + "<key>" + StringEscapeUtils.escapeXml10(node.getNodeName()) + "</key>");
        }

        final List<ImmutableNode> children = node.getChildren();
        if (!children.isEmpty())
        {
            out.println(padding + "<dict>");

            final Iterator<ImmutableNode> it = children.iterator();
            while (it.hasNext())
            {
                final ImmutableNode child = it.next();
                printNode(out, indentLevel + 1, child);

                if (it.hasNext())
                {
                    out.println();
                }
            }

            out.println(padding + "</dict>");
        }
        else if (node.getValue() == null)
        {
            out.println(padding + "<dict/>");
        }
        else
        {
            final Object value = node.getValue();
            printValue(out, indentLevel, value);
        }
    }

    /**
     * Append a value to the writer, indented according to a specific level.
     */
    private void printValue(final PrintWriter out, final int indentLevel, final Object value)
    {
        final String padding = StringUtils.repeat(" ", indentLevel * INDENT_SIZE);

        if (value instanceof Date)
        {
            synchronized (PListNodeBuilder.FORMAT)
            {
                out.println(padding + "<date>" + PListNodeBuilder.FORMAT.format((Date) value) + "</date>");
            }
        }
        else if (value instanceof Calendar)
        {
            printValue(out, indentLevel, ((Calendar) value).getTime());
        }
        else if (value instanceof Number)
        {
            if (value instanceof Double || value instanceof Float || value instanceof BigDecimal)
            {
                out.println(padding + "<real>" + value.toString() + "</real>");
            }
            else
            {
                out.println(padding + "<integer>" + value.toString() + "</integer>");
            }
        }
        else if (value instanceof Boolean)
        {
            if (((Boolean) value).booleanValue())
            {
                out.println(padding + "<true/>");
            }
            else
            {
                out.println(padding + "<false/>");
            }
        }
        else if (value instanceof List)
        {
            out.println(padding + "<array>");
            for (final Object o : (List<?>) value)
            {
                printValue(out, indentLevel + 1, o);
            }
            out.println(padding + "</array>");
        }
        else if (value instanceof HierarchicalConfiguration)
        {
            // This is safe because we have created this configuration
            @SuppressWarnings("unchecked")
            final
            HierarchicalConfiguration<ImmutableNode> config =
                    (HierarchicalConfiguration<ImmutableNode>) value;
            printNode(out, indentLevel, config.getNodeModel().getNodeHandler()
                    .getRootNode());
        }
        else if (value instanceof ImmutableConfiguration)
        {
            // display a flat Configuration as a dictionary
            out.println(padding + "<dict>");

            final ImmutableConfiguration config = (ImmutableConfiguration) value;
            final Iterator<String> it = config.getKeys();
            while (it.hasNext())
            {
                // create a node for each property
                final String key = it.next();
                final ImmutableNode node =
                        new ImmutableNode.Builder().name(key)
                                .value(config.getProperty(key)).create();

                // print the node
                printNode(out, indentLevel + 1, node);

                if (it.hasNext())
                {
                    out.println();
                }
            }
            out.println(padding + "</dict>");
        }
        else if (value instanceof Map)
        {
            // display a Map as a dictionary
            final Map<String, Object> map = transformMap((Map<?, ?>) value);
            printValue(out, indentLevel, new MapConfiguration(map));
        }
        else if (value instanceof byte[])
        {
            String base64;
            try
            {
                base64 = new String(Base64.encodeBase64((byte[]) value), DATA_ENCODING);
            }
            catch (final UnsupportedEncodingException e)
            {
                // Cannot happen as UTF-8 is a standard encoding
                throw new AssertionError(e);
            }
            out.println(padding + "<data>" + StringEscapeUtils.escapeXml10(base64) + "</data>");
        }
        else if (value != null)
        {
            out.println(padding + "<string>" + StringEscapeUtils.escapeXml10(String.valueOf(value)) + "</string>");
        }
        else
        {
            out.println(padding + "<string/>");
        }
    }

    /**
     * Transform a map of arbitrary types into a map with string keys and object
     * values. All keys of the source map which are not of type String are
     * dropped.
     *
     * @param src the map to be converted
     * @return the resulting map
     */
    private static Map<String, Object> transformMap(final Map<?, ?> src)
    {
        final Map<String, Object> dest = new HashMap<>();
        for (final Map.Entry<?, ?> e : src.entrySet())
        {
            if (e.getKey() instanceof String)
            {
                dest.put((String) e.getKey(), e.getValue());
            }
        }
        return dest;
    }

    /**
     * SAX Handler to build the configuration nodes while the document is being parsed.
     */
    private class XMLPropertyListHandler extends DefaultHandler
    {
        /** The buffer containing the text node being read */
        private final StringBuilder buffer = new StringBuilder();

        /** The stack of configuration nodes */
        private final List<PListNodeBuilder> stack = new ArrayList<>();

        /** The builder for the resulting node. */
        private final PListNodeBuilder resultBuilder;

        public XMLPropertyListHandler()
        {
            resultBuilder = new PListNodeBuilder();
            push(resultBuilder);
        }

        /**
         * Returns the builder for the result node.
         *
         * @return the result node builder
         */
        public PListNodeBuilder getResultBuilder()
        {
            return resultBuilder;
        }

        /**
         * Return the node on the top of the stack.
         */
        private PListNodeBuilder peek()
        {
            if (!stack.isEmpty())
            {
                return stack.get(stack.size() - 1);
            }
            return null;
        }

        /**
         * Returns the node on top of the non-empty stack. Throws an exception if the
         * stack is empty.
         *
         * @return the top node of the stack
         * @throws ConfigurationRuntimeException if the stack is empty
         */
        private PListNodeBuilder peekNE()
        {
            final PListNodeBuilder result = peek();
            if (result == null)
            {
                throw new ConfigurationRuntimeException("Access to empty stack!");
            }
            return result;
        }

        /**
         * Remove and return the node on the top of the stack.
         */
        private PListNodeBuilder pop()
        {
            if (!stack.isEmpty())
            {
                return stack.remove(stack.size() - 1);
            }
            return null;
        }

        /**
         * Put a node on the top of the stack.
         */
        private void push(final PListNodeBuilder node)
        {
            stack.add(node);
        }

        @Override
        public void startElement(final String uri, final String localName, final String qName, final Attributes attributes) throws SAXException
        {
            if ("array".equals(qName))
            {
                push(new ArrayNodeBuilder());
            }
            else if ("dict".equals(qName))
            {
                if (peek() instanceof ArrayNodeBuilder)
                {
                    // push the new root builder on the stack
                    push(new PListNodeBuilder());
                }
            }
        }

        @Override
        public void endElement(final String uri, final String localName, final String qName) throws SAXException
        {
            if ("key".equals(qName))
            {
                // create a new node, link it to its parent and push it on the stack
                final PListNodeBuilder node = new PListNodeBuilder();
                node.setName(buffer.toString());
                peekNE().addChild(node);
                push(node);
            }
            else if ("dict".equals(qName))
            {
                // remove the root of the XMLPropertyListConfiguration previously pushed on the stack
                final PListNodeBuilder builder = pop();
                assert builder != null : "Stack was empty!";
                if (peek() instanceof ArrayNodeBuilder)
                {
                    // create the configuration
                    final XMLPropertyListConfiguration config = new XMLPropertyListConfiguration(builder.createNode());

                    // add it to the ArrayNodeBuilder
                    final ArrayNodeBuilder node = (ArrayNodeBuilder) peekNE();
                    node.addValue(config);
                }
            }
            else
            {
                if ("string".equals(qName))
                {
                    peekNE().addValue(buffer.toString());
                }
                else if ("integer".equals(qName))
                {
                    peekNE().addIntegerValue(buffer.toString());
                }
                else if ("real".equals(qName))
                {
                    peekNE().addRealValue(buffer.toString());
                }
                else if ("true".equals(qName))
                {
                    peekNE().addTrueValue();
                }
                else if ("false".equals(qName))
                {
                    peekNE().addFalseValue();
                }
                else if ("data".equals(qName))
                {
                    peekNE().addDataValue(buffer.toString());
                }
                else if ("date".equals(qName))
                {
                    try
                    {
                        peekNE().addDateValue(buffer.toString());
                    }
                    catch (final IllegalArgumentException iex)
                    {
                        getLogger().warn(
                                "Ignoring invalid date property " + buffer);
                    }
                }
                else if ("array".equals(qName))
                {
                    final ArrayNodeBuilder array = (ArrayNodeBuilder) pop();
                    peekNE().addList(array);
                }

                // remove the plist node on the stack once the value has been parsed,
                // array nodes remains on the stack for the next values in the list
                if (!(peek() instanceof ArrayNodeBuilder))
                {
                    pop();
                }
            }

            buffer.setLength(0);
        }

        @Override
        public void characters(final char[] ch, final int start, final int length) throws SAXException
        {
            buffer.append(ch, start, length);
        }
    }

    /**
     * A specialized builder class with addXXX methods to parse the typed data passed by the SAX handler.
     * It is used for creating the nodes of the configuration.
     */
    private static class PListNodeBuilder
    {
        /**
         * The MacOS FORMAT of dates in plist files. Note: Because
         * {@code SimpleDateFormat} is not thread-safe, each access has to be
         * synchronized.
         */
        private static final DateFormat FORMAT = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'");
        static
        {
            FORMAT.setTimeZone(TimeZone.getTimeZone("UTC"));
        }

        /**
         * The GNUstep FORMAT of dates in plist files. Note: Because
         * {@code SimpleDateFormat} is not thread-safe, each access has to be
         * synchronized.
         */
        private static final DateFormat GNUSTEP_FORMAT = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss Z");

        /** A collection with child builders of this builder. */
        private final Collection<PListNodeBuilder> childBuilders =
                new LinkedList<>();

        /** The name of the represented node. */
        private String name;

        /** The current value of the represented node. */
        private Object value;

        /**
         * Update the value of the node. If the existing value is null, it's
         * replaced with the new value. If the existing value is a list, the
         * specified value is appended to the list. If the existing value is
         * not null, a list with the two values is built.
         *
         * @param v the value to be added
         */
        public void addValue(final Object v)
        {
            if (value == null)
            {
                value = v;
            }
            else if (value instanceof Collection)
            {
                // This is safe because we create the collections ourselves
                @SuppressWarnings("unchecked")
                final
                Collection<Object> collection = (Collection<Object>) value;
                collection.add(v);
            }
            else
            {
                final List<Object> list = new ArrayList<>();
                list.add(value);
                list.add(v);
                value = list;
            }
        }

        /**
         * Parse the specified string as a date and add it to the values of the node.
         *
         * @param value the value to be added
         * @throws IllegalArgumentException if the date string cannot be parsed
         */
        public void addDateValue(final String value)
        {
            try
            {
                if (value.indexOf(' ') != -1)
                {
                    // parse the date using the GNUstep FORMAT
                    synchronized (GNUSTEP_FORMAT)
                    {
                        addValue(GNUSTEP_FORMAT.parse(value));
                    }
                }
                else
                {
                    // parse the date using the MacOS X FORMAT
                    synchronized (FORMAT)
                    {
                        addValue(FORMAT.parse(value));
                    }
                }
            }
            catch (final ParseException e)
            {
                throw new IllegalArgumentException(String.format(
                        "'%s' cannot be parsed to a date!", value), e);
            }
        }

        /**
         * Parse the specified string as a byte array in base 64 FORMAT
         * and add it to the values of the node.
         *
         * @param value the value to be added
         */
        public void addDataValue(final String value)
        {
            try
            {
                addValue(Base64.decodeBase64(value.getBytes(DATA_ENCODING)));
            }
            catch (final UnsupportedEncodingException e)
            {
                //Cannot happen as UTF-8 is a default encoding
                throw new AssertionError(e);
            }
        }

        /**
         * Parse the specified string as an Interger and add it to the values of the node.
         *
         * @param value the value to be added
         */
        public void addIntegerValue(final String value)
        {
            addValue(new BigInteger(value));
        }

        /**
         * Parse the specified string as a Double and add it to the values of the node.
         *
         * @param value the value to be added
         */
        public void addRealValue(final String value)
        {
            addValue(new BigDecimal(value));
        }

        /**
         * Add a boolean value 'true' to the values of the node.
         */
        public void addTrueValue()
        {
            addValue(Boolean.TRUE);
        }

        /**
         * Add a boolean value 'false' to the values of the node.
         */
        public void addFalseValue()
        {
            addValue(Boolean.FALSE);
        }

        /**
         * Add a sublist to the values of the node.
         *
         * @param node the node whose value will be added to the current node value
         */
        public void addList(final ArrayNodeBuilder node)
        {
            addValue(node.getNodeValue());
        }

        /**
         * Sets the name of the represented node.
         *
         * @param nodeName the node name
         */
        public void setName(final String nodeName)
        {
            name = nodeName;
        }

        /**
         * Adds the given child builder to this builder.
         *
         * @param child the child builder to be added
         */
        public void addChild(final PListNodeBuilder child)
        {
            childBuilders.add(child);
        }

        /**
         * Creates the configuration node defined by this builder.
         *
         * @return the newly created configuration node
         */
        public ImmutableNode createNode()
        {
            final ImmutableNode.Builder nodeBuilder =
                    new ImmutableNode.Builder(childBuilders.size());
            for (final PListNodeBuilder child : childBuilders)
            {
                nodeBuilder.addChild(child.createNode());
            }
            return nodeBuilder.name(name).value(getNodeValue()).create();
        }

        /**
         * Returns the final value for the node to be created. This method is
         * called when the represented configuration node is actually created.
         *
         * @return the value of the resulting configuration node
         */
        protected Object getNodeValue()
        {
            return value;
        }
    }

    /**
     * Container for array elements. <b>Do not use this class !</b>
     * It is used internally by XMLPropertyConfiguration to parse the
     * configuration file, it may be removed at any moment in the future.
     */
    private static class ArrayNodeBuilder extends PListNodeBuilder
    {
        /** The list of values in the array. */
        private final List<Object> list = new ArrayList<>();

        /**
         * Add an object to the array.
         *
         * @param value the value to be added
         */
        @Override
        public void addValue(final Object value)
        {
            list.add(value);
        }

        /**
         * Return the list of values in the array.
         *
         * @return the {@link List} of values
         */
        @Override
        protected Object getNodeValue()
        {
            return list;
        }
    }
}
