/*
 * Copyright 2011, 2012 Odysseus Software GmbH
 *
 * 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.apache.synapse.commons.staxon.core.json;

import java.io.IOException;

import javax.xml.XMLConstants;
import javax.xml.stream.XMLStreamConstants;
import javax.xml.stream.XMLStreamException;

import org.apache.synapse.commons.staxon.core.base.AbstractXMLStreamWriter;
import org.apache.synapse.commons.staxon.core.json.stream.JsonStreamTarget;

/**
 * JSON XML stream writer.
 * <p/>
 * <h4>Limitations</h4>
 * <ul>
 * <li>Mixed content (e.g. <code>&lt;alice&gt;bob&lt;edgar/&gt;&lt;/alice&gt;</code>) is not supported.</li>
 * <li><code>writeDTD(...)</code> and <code>writeEntityRef(...)</code> are not supported.</li>
 * <li><code>writeCData(...)</code> delegates to writeCharacters(...).</li>
 * <li><code>writeComment(...)</code> does nothing.</li>
 * <li><code>writeProcessingInstruction(...)</code> does nothing (except for target <code>xml-multiple</code>, see below).</li>
 * </ul>
 * <p/>
 * <p>The writer may consume processing instructions
 * (e.g. <code>&lt;?xml-multiple element-name?&gt;</code>) to properly insert JSON array tokens (<code>'['</code>
 * and <code>']'</code>). The client provides this instruction through the
 * {@link #writeProcessingInstruction(String, String)} method,
 * passing the (possibly prefixed) field name as data e.g.</p>
 * <pre>
 *   ...
 *   writer.writeProcessingInstruction("xml-multiple", "item");
 *   for (Item item : items) {
 *     writer.writeStartElement("item");
 *     ...
 *     writer.writeEndElement();
 *   }
 *   ...
 * </pre>
 * <p>The element name passed as processing instruction data is optional.
 * If omitted, the next element within the current scope will start an array. Note, that this method
 * does not allow to create empty arrays (in fact, the above code sample could create unexpected results,
 * if the name would have been omitted and collection were empty).</p>
 */
public class JsonXMLStreamWriter extends AbstractXMLStreamWriter<JsonXMLStreamWriter.ScopeInfo> {
    private static final String XML_SCHEMA_NIL_VALUE = "org.apache.synapse.commons.staxon.core.json.JsonXMLStreamWriter.{http://www.w3.org/2001/XMLSchema-instance}#nil=true_";
    private static final String XSI_NIL = "nil";
    private static final String XSI_NS = "http://www.w3.org/2001/XMLSchema-instance";

    static class ScopeInfo extends JsonXMLStreamScopeInfo {
        private Object leadData = null;
        private StringBuilder builder = null;
        boolean startObjectWritten = false;
        boolean pendingStartArray = false;

        void addText(String data) {
            if (leadData == null) { // first event?
                this.leadData = data;
            } else {
                if (builder == null) { // second event?
                    builder = new StringBuilder(leadData.toString());
                }
                builder.append(data);
            }
        }

        boolean hasData() {
            return leadData != null;
        }

        Object getData() {
            return builder == null ? (hasData() ? leadData : null) : builder.toString();
        }

        void setData(Object data) {
            this.leadData = data;
            this.builder = null;
        }
    }

    static boolean isWhitespace(Object data) {
        if (data == null) {
            return false;
        }
        String text = data.toString();
        for (int i = 0; i < text.length(); i++) {
            if (!Character.isWhitespace(text.charAt(i))) {
                return false;
            }
        }
        return true;
    }

    private final JsonStreamTarget target;
    private final boolean multiplePI;
    private final boolean autoEndArray;
    private final boolean skipSpace;
    private final char namespaceSeparator;
    private final boolean namespaceDeclarations;
    private final boolean xmlNilReadWriteEnabled;
    private final boolean xmlWriteNullForEmptyElement;

    private boolean documentArray = false;

    /**
     * Create writer instance.
     *
     * @param target                stream target
     * @param multiplePI            whether to consume <code>&lt;xml-multiple?&gt;</code> PIs to trigger array start
     * @param namespaceSeparator    namespace prefix separator
     * @param namespaceDeclarations whether to write namespace declarations
     * @param repairNamespaces      repair Namespaces
     */
    public JsonXMLStreamWriter(JsonStreamTarget target, boolean repairNamespaces, boolean multiplePI, char namespaceSeparator, boolean namespaceDeclarations) {
        super(new ScopeInfo(), repairNamespaces);
        this.target = target;
        this.multiplePI = multiplePI;
        this.namespaceSeparator = namespaceSeparator;
        this.namespaceDeclarations = namespaceDeclarations;
        this.autoEndArray = true;
        this.skipSpace = true;
        this.xmlNilReadWriteEnabled = false;
        this.xmlWriteNullForEmptyElement = true;
    }

    /**
     * Create writer instance.
     *
     * @param target                stream target
     * @param multiplePI            whether to consume <code>&lt;xml-multiple?&gt;</code> PIs to trigger array start
     * @param namespaceSeparator    namespace prefix separator
     * @param namespaceDeclarations whether to write namespace declarations
     * @param xmlNilReadWriteEnabled    Supports reading and writing of XML Nil elements as defined by http://www.w3.org/TR/xmlschema-1/#xsi_nil when the XML/JSON inputs contains nil/null values.
     * @param repairNamespaces      repair Namespaces
     * @param xmlWriteNullForEmptyElement   write null or "" for XML empty elements without nil attribute
     */
    public JsonXMLStreamWriter(JsonStreamTarget target, boolean repairNamespaces, boolean multiplePI, char namespaceSeparator, boolean namespaceDeclarations,
                               boolean xmlNilReadWriteEnabled, boolean xmlWriteNullForEmptyElement) {
        super(new ScopeInfo(), repairNamespaces);
        this.target = target;
        this.multiplePI = multiplePI;
        this.namespaceSeparator = namespaceSeparator;
        this.namespaceDeclarations = namespaceDeclarations;
        this.autoEndArray = true;
        this.skipSpace = true;
        this.xmlNilReadWriteEnabled = xmlNilReadWriteEnabled;
        this.xmlWriteNullForEmptyElement = xmlWriteNullForEmptyElement;
    }

    private String getFieldName(String prefix, String localName) {
        return XMLConstants.DEFAULT_NS_PREFIX.equals(prefix) ? localName : prefix + namespaceSeparator + localName;
    }

    @Override
    protected ScopeInfo writeStartElementTag(String prefix, String localName, String namespaceURI) throws XMLStreamException {
        ScopeInfo parentInfo = getScope().getInfo();
        if (parentInfo.hasData()) {
            if (!skipSpace || !isWhitespace(parentInfo.getData())) {
                throw new XMLStreamException("Mixed content is not supported: '" + parentInfo.getData() + "'");
            }
            parentInfo.setData(null);
        }
        String fieldName = getFieldName(prefix, localName);
        if (getScope().isRoot() && getScope().getLastChild() != null && !documentArray) {
            if (!fieldName.equals(parentInfo.getArrayName())) {
                throw new XMLStreamException("Multiple roots within document");
            }
        }
        if (parentInfo.pendingStartArray) {
            writeStartArray(fieldName);
        }
        try {
            if (!parentInfo.isArray()) {
                if (!parentInfo.startObjectWritten) {
                    target.startObject();
                    parentInfo.startObjectWritten = true;
                }
            } else if (autoEndArray && !fieldName.equals(parentInfo.getArrayName())) {
                writeEndArray();
            }
            if (!parentInfo.isArray()) {
                target.name(fieldName);
            } else {
                parentInfo.incArraySize();
            }
        } catch (IOException e) {
            throw new XMLStreamException("Cannot write start element: " + fieldName, e);
        }
        return new ScopeInfo();
    }

    @Override
    protected void writeStartElementTagEnd() throws XMLStreamException {
        if (getScope().isEmptyElement()) {
            writeEndElementTag();
        }
    }

    @Override
    protected void writeEndElementTag() throws XMLStreamException {
        try {
            if (getScope().getInfo().hasData()) {
                if (xmlNilReadWriteEnabled && XML_SCHEMA_NIL_VALUE.equals(getScope().getInfo().getData())) {
                    target.value(null);
                    return;
                }
                if (getScope().getInfo().startObjectWritten) {
                    target.name("$");
                }
                target.value(getScope().getInfo().getData());
            }
            if (autoEndArray && getScope().getInfo().isArray()) {
                writeEndArray();
            }
            if (getScope().getInfo().startObjectWritten) {
                target.endObject();
            } else if (!getScope().getInfo().hasData()) {
                if (xmlWriteNullForEmptyElement) {
                    target.value(null);
                } else {
                    target.value("");
                }
            }
        } catch (IOException e) {
            throw new XMLStreamException("Cannot write end element: " + getFieldName(getScope().getPrefix(), getScope().getLocalName()), e);
        }
    }

    public void writeAttribute(String prefix, String namespaceURI, String localName, String value) throws XMLStreamException {
        if (xmlNilReadWriteEnabled && (XSI_NS.equals(namespaceURI) || "".equals(namespaceURI)) &&
                XSI_NIL.equals(localName) &&
                "true".equals(value)) {
            getScope().getInfo().setData(XML_SCHEMA_NIL_VALUE);
            return;
        }
        super.writeAttribute(prefix, namespaceURI, localName, value);
    }

    public void writeNamespace(String prefix, String namespaceURI) throws XMLStreamException {
        if (xmlNilReadWriteEnabled && XSI_NS.equals(namespaceURI)) {
            return;
        }
        super.writeNamespace(prefix, namespaceURI);
    }

    @Override
    protected void writeAttr(String prefix, String localName, String namespaceURI, String value) throws XMLStreamException {
        String name = XMLConstants.DEFAULT_NS_PREFIX.equals(prefix) ? localName : prefix + namespaceSeparator + localName;
        try {
            if (!getScope().getInfo().startObjectWritten) {
                target.startObject();
                getScope().getInfo().startObjectWritten = true;
            }
            target.name('@' + name);
            target.value(value);
        } catch (IOException e) {
            throw new XMLStreamException("Cannot write attribute: " + name, e);
        }
    }

    @Override
    protected void writeNsDecl(String prefix, String namespaceURI) throws XMLStreamException {
        if (namespaceDeclarations) {
            try {
                if (!getScope().getInfo().startObjectWritten) {
                    target.startObject();
                    getScope().getInfo().startObjectWritten = true;
                }
                if (XMLConstants.DEFAULT_NS_PREFIX.equals(prefix)) {
                    target.name('@' + XMLConstants.XMLNS_ATTRIBUTE);
                } else {
                    target.name('@' + XMLConstants.XMLNS_ATTRIBUTE + namespaceSeparator + prefix);
                }
                target.value(namespaceURI);
            } catch (IOException e) {
                throw new XMLStreamException("Cannot write namespace declaration: " + namespaceURI, e);
            }
        }
    }

    @Override
    protected void writeData(Object data, int type) throws XMLStreamException {
        switch (type) {
            case XMLStreamConstants.CHARACTERS:
            case XMLStreamConstants.CDATA:
                if (getScope().isRoot() && !isStartDocumentWritten()) { // hack: allow to write simple value
                    try {
                        target.value(data);
                    } catch (IOException e) {
                        throw new XMLStreamException("Cannot write data", e);
                    }
                } else {
                    if (data == null) {
                        throw new XMLStreamException("Cannot write null data");
                    }
                    if (getScope().getLastChild() != null) {
                        if (!skipSpace || !isWhitespace(data)) {
                            throw new XMLStreamException("Mixed content is not supported: '" + data + "'");
                        }
                    } else if (getScope().getInfo().hasData()) {
                        if (data instanceof String) {
                            getScope().getInfo().addText(data.toString());
                        } else {
                            throw new XMLStreamException("Cannot append primitive data: " + data);
                        }
                    } else {
                        getScope().getInfo().setData(data);
                    }
                }
                break;
            case XMLStreamConstants.COMMENT: // ignore comments
                break;
            default:
                throw new UnsupportedOperationException("Cannot write data of type " + type);
        }
    }

    @Override
    public void writeStartDocument(String encoding, String version) throws XMLStreamException {
        super.writeStartDocument(encoding, version);
        try {
            target.startObject();
        } catch (IOException e) {
            throw new XMLStreamException("Cannot start document", e);
        }
        getScope().getInfo().startObjectWritten = true;
    }


    @Override
    public void writeEndDocument() throws XMLStreamException {
        super.writeEndDocument();
        try {
            if (getScope().getInfo().isArray()) {
                target.endArray();
            }
            target.endObject();
        } catch (IOException e) {
            throw new XMLStreamException("Cannot end document", e);
        }
        getScope().getInfo().startObjectWritten = false;
    }

    public void writeStartArray(String fieldName) throws XMLStreamException {
        if (autoEndArray && getScope().getInfo().isArray()) {
            writeEndArray();
        }
        getScope().getInfo().startArray(fieldName);
        getScope().getInfo().pendingStartArray = false;
        try {
            if (!getScope().getInfo().startObjectWritten) {
                target.startObject();
                getScope().getInfo().startObjectWritten = true;
            }
            target.name(fieldName);
            target.startArray();
        } catch (IOException e) {
            throw new XMLStreamException("Cannot start array: " + fieldName, e);
        }
    }

    public void writeEndArray() throws XMLStreamException {
        getScope().getInfo().endArray();
        try {
            target.endArray();
        } catch (IOException e) {
            throw new XMLStreamException("Cannot end array: " + getScope().getInfo().getArrayName(), e);
        }
    }

    @Override
    public void close() throws XMLStreamException {
        super.close();
        try {
            if (documentArray) {
                target.endArray();
            }
            target.close();
        } catch (IOException e) {
            throw new XMLStreamException("Close failed", e);
        }
    }

    @Override
    public void flush() throws XMLStreamException {
        try {
            target.flush();
        } catch (IOException e) {
            throw new XMLStreamException("Flush failed", e);
        }
    }

    @Override
    protected void writePI(String target, String data) throws XMLStreamException {
        if (multiplePI && JsonXMLStreamConstants.MULTIPLE_PI_TARGET.equals(target)) {
            if (getScope().isRoot() && !isStartDocumentWritten()) {
                if (data == null || data.trim().isEmpty()) {
                    try {
                        this.target.startArray();
                        this.documentArray = true;
                    } catch (IOException e) {
                        throw new XMLStreamException("Cannot start document array", e);
                    }
                } else {
                    throw new XMLStreamException("Cannot specify name in document array: " + data);
                }
            } else {
                if (data == null || data.trim().isEmpty()) {
                    getScope().getInfo().pendingStartArray = true;
                } else {
                    writeStartArray(data.trim());
                }
            }
        }
    }

    /**
     * Write number value.
     *
     * @param value
     * @throws XMLStreamException
     */
    public void writeNumber(Number value) throws XMLStreamException {
        if (getScope().getInfo().hasData()) {
            throw new XMLStreamException("Cannot write number value");
        }
        super.writeCharacters(value, XMLStreamConstants.CHARACTERS);
    }

    /**
     * Write boolean value.
     *
     * @param value
     * @throws XMLStreamException
     */
    public void writeBoolean(Boolean value) throws XMLStreamException {
        if (getScope().getInfo().hasData()) {
            throw new XMLStreamException("Cannot write boolean value");
        }
        super.writeCharacters(value, XMLStreamConstants.CHARACTERS);
    }
}
