/*
 * Copyright 2002-2009 the original author or authors.
 *
 * 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.json.xml;

import java.io.BufferedReader;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.StringReader;
import java.io.UnsupportedEncodingException;
import java.util.Arrays;
import java.util.Iterator;
import java.util.Map;
import java.util.TreeMap;
import net.sf.json.JSON;
import net.sf.json.JSONArray;
import net.sf.json.JSONException;
import net.sf.json.JSONFunction;
import net.sf.json.JSONNull;
import net.sf.json.JSONObject;
import net.sf.json.util.JSONUtils;
import nu.xom.Attribute;
import nu.xom.Builder;
import nu.xom.Document;
import nu.xom.Element;
import nu.xom.Elements;
import nu.xom.Node;
import nu.xom.Serializer;
import nu.xom.Text;
import org.apache.commons.lang.ArrayUtils;
import org.apache.commons.lang.StringUtils;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;

/**
 * Utility class for transforming JSON to XML an back.<br>
 * When transforming JSONObject and JSONArray instances to XML, this class will
 * add hints for converting back to JSON.<br>
 * Examples:<br>
 *
 * <pre>
 * JSONObject json = JSONObject.fromObject("{\"name\":\"json\",\"bool\":true,\"int\":1}");
 * String xml = new XMLSerializer().write( json );
 * &lt;xmp&gt;&lt;o class="object"&gt;
 * &lt;name type="string"&gt;json&lt;/name&gt;
 * &lt;bool type="boolean"&gt;true&lt;/bool&gt;
 * &lt;int type="number"&gt;1&lt;/int&gt;
 * &lt;/o&gt;&lt;/xmp&gt;
 * </pre><pre>
 * JSONArray json = JSONArray.fromObject("[1,2,3]");
 * String xml = new XMLSerializer().write( json );
 * &lt;xmp&gt;&lt;a class="array"&gt;
 * &lt;e type="number"&gt;1&lt;/e&gt;
 * &lt;e type="number"&gt;2&lt;/e&gt;
 * &lt;e type="number"&gt;3&lt;/e&gt;
 * &lt;/a&gt;&lt;/xmp&gt;
 * </pre>
 *
 * @author Andres Almiray <a href="mailto:aalmiray@users.sourceforge.net">aalmiray@users.sourceforge.net</a>
 */
public class XMLSerializer {
    private static final String[] EMPTY_ARRAY = new String[0];
    private static final String JSON_PREFIX = "json_";
    private static final Log log = LogFactory.getLog(XMLSerializer.class);

    /** the name for an JSONArray Element */
    private String arrayName;
    /** the name for an JSONArray's element Element */
    private String elementName;
    /** list of properties to be expanded from child to parent */
    private String[] expandableProperties;

    private boolean forceTopLevelObject;
    /** flag to be tolerant for incomplete namespace prefixes */
    private boolean namespaceLenient;
    /** Map of namespaces per element */
    private Map namespacesPerElement = new TreeMap();
    /** the name for an JSONObject Element */
    private String objectName;
    /** flag for trimming namespace prefix from element name */
    private boolean removeNamespacePrefixFromElements;
    /** the name for the root Element */
    private String rootName;
    /** Map of namespaces for root element */
    private Map rootNamespace = new TreeMap();
    /** flag for skipping namespaces while reading */
    private boolean skipNamespaces;
    /** flag for skipping whitespace elements while reading */
    private boolean skipWhitespace;
    /** flag for trimming spaces from string values */
    private boolean trimSpaces;
    /** flag for type hints naming compatibility */
    private boolean typeHintsCompatibility;
    /** flag for adding JSON types hints as attributes */
    private boolean typeHintsEnabled;

    /**
     * Creates a new XMLSerializer with default options.<br>
     * <ul>
     * <li><code>objectName</code>: 'o'</li>
     * <li><code>arrayName</code>: 'a'</li>
     * <li><code>elementName</code>: 'e'</li>
     * <li><code>typeHinstEnabled</code>: true</li>
     * <li><code>typeHinstCompatibility</code>: true</li>
     * <li><code>namespaceLenient</code>: false</li>
     * <li><code>expandableProperties</code>: []</li>
     * <li><code>skipNamespaces</code>: false</li>
     * <li><code>removeNameSpacePrefixFromElement</code>: false</li>
     * <li><code>trimSpaces</code>: false</li>
     * </ul>
     */
    public XMLSerializer() {
        setObjectName("o");
        setArrayName("a");
        setElementName("e");
        setTypeHintsEnabled(true);
        setTypeHintsCompatibility(true);
        setNamespaceLenient(false);
        setSkipNamespaces(false);
        setRemoveNamespacePrefixFromElements(false);
        setTrimSpaces(false);
        setExpandableProperties(EMPTY_ARRAY);
        setSkipNamespaces(false);
    }

    /**
     * Adds a namespace declaration to the root element.
     *
     * @param prefix namespace prefix
     * @param uri namespace uri
     */
    public void addNamespace(String prefix, String uri) {
        addNamespace(prefix, uri, null);
    }

    /**
     * Adds a namespace declaration to an element.<br>
     * If the elementName param is null or blank, the namespace declaration will
     * be added to the root element.
     *
     * @param prefix namespace prefix
     * @param uri namespace uri
     * @param elementName name of target element
     */
    public void addNamespace(String prefix, String uri, String elementName) {
        if (uri == null || uri.isBlank()) {
            return;
        }
        if (prefix == null) {
            prefix = "";
        }
        if (elementName == null || elementName.isBlank()) {
            rootNamespace.put(prefix.trim(), uri.trim());
        } else {
            Map nameSpaces = (Map) namespacesPerElement.get(elementName);
            if (nameSpaces == null) {
                nameSpaces = new TreeMap();
                namespacesPerElement.put(elementName, nameSpaces);
            }
            nameSpaces.put(prefix, uri);
        }
    }

    /**
     * Removes all namespaces declarations (from root an elements).
     */
    public void clearNamespaces() {
        rootNamespace.clear();
        namespacesPerElement.clear();
    }

    /**
     * Removes all namespace declarations from an element.<br>
     * If the elementName param is null or blank, the declarations will be
     * removed from the root element.
     *
     * @param elementName name of target element
     */
    public void clearNamespaces(String elementName) {
        if (elementName == null || elementName.isBlank()) {
            rootNamespace.clear();
        } else {
            namespacesPerElement.remove(elementName);
        }
    }

    /**
     * Returns the name used for JSONArray.
     */
    public String getArrayName() {
        return arrayName;
    }

    /**
     * Returns the name used for JSONArray elements.
     */
    public String getElementName() {
        return elementName;
    }

    /**
     * Returns a list of properties to be expanded from child to parent.
     */
    public String[] getExpandableProperties() {
        return expandableProperties;
    }

    /**
     * Returns the name used for JSONArray.
     */
    public String getObjectName() {
        return objectName;
    }

    /**
     * Returns the name used for the root element.
     */
    public String getRootName() {
        return rootName;
    }

    public boolean isForceTopLevelObject() {
        return forceTopLevelObject;
    }

    /**
     * Returns wether this serializer is tolerant to namespaces without URIs or
     * not.
     */
    public boolean isNamespaceLenient() {
        return namespaceLenient;
    }

    /**
     * Returns wether this serializer will remove namespace prefix from elements
     * or not.
     */
    public boolean isRemoveNamespacePrefixFromElements() {
        return removeNamespacePrefixFromElements;
    }

    /**
     * Returns wether this serializer will skip adding namespace declarations to
     * elements or not.
     */
    public boolean isSkipNamespaces() {
        return skipNamespaces;
    }

    /**
     * Returns wether this serializer will skip whitespace or not.
     */
    public boolean isSkipWhitespace() {
        return skipWhitespace;
    }

    /**
     * Returns wether this serializer will trim leading and trealing whitespace
     * from values or not.
     */
    public boolean isTrimSpaces() {
        return trimSpaces;
    }

    /**
     * Returns true if types hints will have a 'json_' prefix or not.
     */
    public boolean isTypeHintsCompatibility() {
        return typeHintsCompatibility;
    }

    /**
     * Returns true if JSON types will be included as attributes.
     */
    public boolean isTypeHintsEnabled() {
        return typeHintsEnabled;
    }

    /**
     * Creates a JSON value from a XML string.
     *
     * @param xml A well-formed xml document in a String
     * @return a JSONNull, JSONObject or JSONArray
     * @throws JSONException if the conversion from XML to JSON can't be made for
     *         I/O or format reasons.
     */
    public JSON read(String xml) {
        JSON json = null;
        try {
            Document doc = new Builder().build(new StringReader(xml));
            Element root = doc.getRootElement();
            if (isNullObject(root)) {
                return JSONNull.getInstance();
            }
            String defaultType = getType(root, JSONTypes.STRING);
            if (isArray(root, true)) {
                json = processArrayElement(root, defaultType);
                if (forceTopLevelObject) {
                    String key = removeNamespacePrefix(root.getQualifiedName());
                    json = new JSONObject().element(key, json);
                }
            } else {
                json = processObjectElement(root, defaultType);
                if (forceTopLevelObject) {
                    String key = removeNamespacePrefix(root.getQualifiedName());
                    json = new JSONObject().element(key, json);
                }
            }
        } catch (JSONException jsone) {
            throw jsone;
        } catch (Exception e) {
            throw new JSONException(e);
        }
        return json;
    }

    /**
     * Creates a JSON value from a File.
     *
     * @param file
     * @return a JSONNull, JSONObject or JSONArray
     * @throws JSONException if the conversion from XML to JSON can't be made for
     *         I/O or format reasons.
     */
    public JSON readFromFile(File file) {
        if (file == null) {
            throw new JSONException("File is null");
        }
        if (!file.canRead()) {
            throw new JSONException("Can't read input file");
        }
        if (file.isDirectory()) {
            throw new JSONException("File is a directory");
        }
        try {
            return readFromStream(new FileInputStream(file));
        } catch (IOException ioe) {
            throw new JSONException(ioe);
        }
    }

    /**
     * Creates a JSON value from a File.
     *
     * @param path
     * @return a JSONNull, JSONObject or JSONArray
     * @throws JSONException if the conversion from XML to JSON can't be made for
     *         I/O or format reasons.
     */
    public JSON readFromFile(String path) {
        return readFromStream(Thread.currentThread().getContextClassLoader().getResourceAsStream(path));
    }

    /**
     * Creates a JSON value from an input stream.
     *
     * @param stream
     * @return a JSONNull, JSONObject or JSONArray
     * @throws JSONException if the conversion from XML to JSON can't be made for
     *         I/O or format reasons.
     */
    public JSON readFromStream(InputStream stream) {
        try {
            StringBuffer xml = new StringBuffer();
            BufferedReader in = new BufferedReader(new InputStreamReader(stream));
            String line = null;
            while ((line = in.readLine()) != null) {
                xml.append(line);
            }
            return read(xml.toString());
        } catch (IOException ioe) {
            throw new JSONException(ioe);
        }
    }

    /**
     * Removes a namespace from the root element.
     *
     * @param prefix namespace prefix
     */
    public void removeNamespace(String prefix) {
        removeNamespace(prefix, null);
    }

    /**
     * Removes a namespace from the root element.<br>
     * If the elementName is null or blank, the namespace will be removed from
     * the root element.
     *
     * @param prefix namespace prefix
     * @param elementName name of target element
     */
    public void removeNamespace(String prefix, String elementName) {
        if (prefix == null) {
            prefix = "";
        }
        if (elementName == null || elementName.isBlank()) {
            rootNamespace.remove(prefix.trim());
        } else {
            Map nameSpaces = (Map) namespacesPerElement.get(elementName);
            nameSpaces.remove(prefix);
        }
    }

    /**
     * Sets the name used for JSONArray.<br>
     * Default is 'a'.
     */
    public void setArrayName(String arrayName) {
        this.arrayName = arrayName == null || arrayName.isBlank() ? "a" : arrayName;
    }

    /**
     * Sets the name used for JSONArray elements.<br>
     * Default is 'e'.
     */
    public void setElementName(String elementName) {
        this.elementName = elementName == null || elementName.isBlank() ? "e" : elementName;
    }

    /**
     * Sets the list of properties to be expanded from child to parent.
     */
    public void setExpandableProperties(String[] expandableProperties) {
        this.expandableProperties = expandableProperties == null ? EMPTY_ARRAY : expandableProperties;
    }

    public void setForceTopLevelObject(boolean forceTopLevelObject) {
        this.forceTopLevelObject = forceTopLevelObject;
    }

    /**
     * Sets the namespace declaration to the root element.<br>
     * Any previous values are discarded.
     *
     * @param prefix namespace prefix
     * @param uri namespace uri
     */
    public void setNamespace(String prefix, String uri) {
        setNamespace(prefix, uri, null);
    }

    /**
     * Adds a namespace declaration to an element.<br>
     * Any previous values are discarded. If the elementName param is null or
     * blank, the namespace declaration will be added to the root element.
     *
     * @param prefix namespace prefix
     * @param uri namespace uri
     * @param elementName name of target element
     */
    public void setNamespace(String prefix, String uri, String elementName) {
        if (uri == null || uri.isBlank()) {
            return;
        }
        if (prefix == null) {
            prefix = "";
        }
        if (elementName == null || elementName.isBlank()) {
            rootNamespace.clear();
            rootNamespace.put(prefix.trim(), uri.trim());
        } else {
            Map nameSpaces = (Map) namespacesPerElement.get(elementName);
            if (nameSpaces == null) {
                nameSpaces = new TreeMap();
                namespacesPerElement.put(elementName, nameSpaces);
            }
            nameSpaces.clear();
            nameSpaces.put(prefix, uri);
        }
    }

    /**
     * Sets wether this serializer is tolerant to namespaces without URIs or not.
     */
    public void setNamespaceLenient(boolean namespaceLenient) {
        this.namespaceLenient = namespaceLenient;
    }

    /**
     * Sets the name used for JSONObject.<br>
     * Default is 'o'.
     */
    public void setObjectName(String objectName) {
        this.objectName = objectName == null || objectName.isBlank() ? "o" : objectName;
    }

    /**
     * Sets if this serializer will remove namespace prefix from elements when
     * reading.
     */
    public void setRemoveNamespacePrefixFromElements(boolean removeNamespacePrefixFromElements) {
        this.removeNamespacePrefixFromElements = removeNamespacePrefixFromElements;
    }

    /**
     * Sets the name used for the root element.
     */
    public void setRootName(String rootName) {
        this.rootName = rootName == null || rootName.isBlank() ? null : rootName;
    }

    /**
     * Sets if this serializer will skip adding namespace declarations to
     * elements when reading.
     */
    public void setSkipNamespaces(boolean skipNamespaces) {
        this.skipNamespaces = skipNamespaces;
    }

    /**
     * Sets if this serializer will skip whitespace when reading.
     */
    public void setSkipWhitespace(boolean skipWhitespace) {
        this.skipWhitespace = skipWhitespace;
    }

    /**
     * Sets if this serializer will trim leading and trealing whitespace from
     * values when reading.
     */
    public void setTrimSpaces(boolean trimSpaces) {
        this.trimSpaces = trimSpaces;
    }

    /**
     * Sets wether types hints will have a 'json_' prefix or not.
     */
    public void setTypeHintsCompatibility(boolean typeHintsCompatibility) {
        this.typeHintsCompatibility = typeHintsCompatibility;
    }

    /**
     * Sets wether JSON types will be included as attributes.
     */
    public void setTypeHintsEnabled(boolean typeHintsEnabled) {
        this.typeHintsEnabled = typeHintsEnabled;
    }

    /**
     * Writes a JSON value into a XML string with UTF-8 encoding.<br>
     *
     * @param json The JSON value to transform
     * @return a String representation of a well-formed xml document.
     * @throws JSONException if the conversion from JSON to XML can't be made for
     *         I/O reasons.
     */
    public String write(JSON json) {
        return write(json, null);
    }

    /**
     * Writes a JSON value into a XML string with an specific encoding.<br>
     * If the encoding string is null it will use UTF-8.
     *
     * @param json The JSON value to transform
     * @param encoding The xml encoding to use
     * @return a String representation of a well-formed xml document.
     * @throws JSONException if the conversion from JSON to XML can't be made for
     *         I/O reasons or the encoding is not supported.
     */
    public String write(JSON json, String encoding) {
        if (JSONNull.getInstance().equals(json)) {
            Element root = null;
            root = newElement(getRootName() == null ? getObjectName() : getRootName());
            root.addAttribute(new Attribute(addJsonPrefix("null"), "true"));
            Document doc = new Document(root);
            return writeDocument(doc, encoding);
        } else if (json instanceof JSONArray) {
            JSONArray jsonArray = (JSONArray) json;
            Element root = processJSONArray(
                    jsonArray,
                    newElement(getRootName() == null ? getArrayName() : getRootName()),
                    expandableProperties);
            Document doc = new Document(root);
            return writeDocument(doc, encoding);
        } else {
            JSONObject jsonObject = (JSONObject) json;
            Element root = null;
            if (jsonObject.isNullObject()) {
                root = newElement(getObjectName());
                root.addAttribute(new Attribute(addJsonPrefix("null"), "true"));
            } else {
                root = processJSONObject(
                        jsonObject,
                        newElement(getRootName() == null ? getObjectName() : getRootName()),
                        expandableProperties,
                        true);
            }
            Document doc = new Document(root);
            return writeDocument(doc, encoding);
        }
    }

    private String addJsonPrefix(String str) {
        if (!isTypeHintsCompatibility()) {
            return JSON_PREFIX + str;
        }
        return str;
    }

    private void addNameSpaceToElement(Element element) {
        String elementName = null;
        if (element instanceof CustomElement) {
            elementName = ((CustomElement) element).getQName();
        } else {
            elementName = element.getQualifiedName();
        }
        Map nameSpaces = (Map) namespacesPerElement.get(elementName);
        if (nameSpaces != null && !nameSpaces.isEmpty()) {
            setNamespaceLenient(true);
            for (Iterator entries = nameSpaces.entrySet().iterator(); entries.hasNext(); ) {
                Map.Entry entry = (Map.Entry) entries.next();
                String prefix = (String) entry.getKey();
                String uri = (String) entry.getValue();
                if (prefix == null || prefix.isBlank()) {
                    element.setNamespaceURI(uri);
                } else {
                    element.addNamespaceDeclaration(prefix, uri);
                }
            }
        }
    }

    private boolean checkChildElements(Element element, boolean isTopLevel) {
        int childCount = element.getChildCount();
        Elements elements = element.getChildElements();
        int elementCount = elements.size();

        if (childCount == 1 && element.getChild(0) instanceof Text) {
            return isTopLevel;
        }

        if (childCount == elementCount) {
            if (elementCount == 0) {
                return true;
            }
            if (elementCount == 1) {
                if (skipWhitespace || element.getChild(0) instanceof Text) {
                    return true;
                } else {
                    return false;
                }
            }
        }

        if (childCount > elementCount) {
            for (int i = 0; i < childCount; i++) {
                Node node = element.getChild(i);
                if (node instanceof Text) {
                    Text text = (Text) node;
                    String stripped = text.getValue() != null ? text.getValue().strip() : null;
                    if (stripped != null && !stripped.isBlank() && !skipWhitespace) {
                        return false;
                    }
                }
            }
        }

        String childName = elements.get(0).getQualifiedName();
        for (int i = 1; i < elementCount; i++) {
            if (childName.compareTo(elements.get(i).getQualifiedName()) != 0) {
                return false;
            }
        }

        return true;
    }

    private String getClass(Element element) {
        Attribute attribute = element.getAttribute(addJsonPrefix("class"));
        String clazz = null;
        if (attribute != null) {
            String clazzText = attribute.getValue().trim();
            if (JSONTypes.OBJECT.compareToIgnoreCase(clazzText) == 0) {
                clazz = JSONTypes.OBJECT;
            } else if (JSONTypes.ARRAY.compareToIgnoreCase(clazzText) == 0) {
                clazz = JSONTypes.ARRAY;
            }
        }
        return clazz;
    }

    private String getType(Element element) {
        return getType(element, null);
    }

    private String getType(Element element, String defaultType) {
        Attribute attribute = element.getAttribute(addJsonPrefix("type"));
        String type = null;
        if (attribute != null) {
            String typeText = attribute.getValue().trim();
            if (JSONTypes.BOOLEAN.compareToIgnoreCase(typeText) == 0) {
                type = JSONTypes.BOOLEAN;
            } else if (JSONTypes.NUMBER.compareToIgnoreCase(typeText) == 0) {
                type = JSONTypes.NUMBER;
            } else if (JSONTypes.INTEGER.compareToIgnoreCase(typeText) == 0) {
                type = JSONTypes.INTEGER;
            } else if (JSONTypes.FLOAT.compareToIgnoreCase(typeText) == 0) {
                type = JSONTypes.FLOAT;
            } else if (JSONTypes.OBJECT.compareToIgnoreCase(typeText) == 0) {
                type = JSONTypes.OBJECT;
            } else if (JSONTypes.ARRAY.compareToIgnoreCase(typeText) == 0) {
                type = JSONTypes.ARRAY;
            } else if (JSONTypes.STRING.compareToIgnoreCase(typeText) == 0) {
                type = JSONTypes.STRING;
            } else if (JSONTypes.FUNCTION.compareToIgnoreCase(typeText) == 0) {
                type = JSONTypes.FUNCTION;
            }
        } else {
            if (defaultType != null) {
                log.info("Using default type " + defaultType);
                type = defaultType;
            }
        }
        return type;
    }

    private boolean hasNamespaces(Element element) {
        int namespaces = 0;
        for (int i = 0; i < element.getNamespaceDeclarationCount(); i++) {
            String prefix = element.getNamespacePrefix(i);
            String uri = element.getNamespaceURI(prefix);
            if (uri == null || uri.isBlank()) {
                continue;
            }
            namespaces++;
        }
        return namespaces > 0;
    }

    private boolean isArray(Element element, boolean isTopLevel) {
        boolean isArray = false;
        String clazz = getClass(element);
        if (clazz != null && clazz.equals(JSONTypes.ARRAY)) {
            isArray = true;
        } else if (element.getAttributeCount() == 0) {
            isArray = checkChildElements(element, isTopLevel);
        } else if (element.getAttributeCount() == 1
                && (element.getAttribute(addJsonPrefix("class")) != null
                        || element.getAttribute(addJsonPrefix("type")) != null)) {
            isArray = checkChildElements(element, isTopLevel);
        } else if (element.getAttributeCount() == 2
                && (element.getAttribute(addJsonPrefix("class")) != null
                        && element.getAttribute(addJsonPrefix("type")) != null)) {
            isArray = checkChildElements(element, isTopLevel);
        }

        if (isArray) {
            // check namespace
            for (int j = 0; j < element.getNamespaceDeclarationCount(); j++) {
                String prefix = element.getNamespacePrefix(j);
                String uri = element.getNamespaceURI(prefix);
                if (uri != null && !uri.isBlank()) {
                    return false;
                }
            }
        }

        return isArray;
    }

    private boolean isFunction(Element element) {
        int attrCount = element.getAttributeCount();
        if (attrCount > 0) {
            Attribute typeAttr = element.getAttribute(addJsonPrefix("type"));
            Attribute paramsAttr = element.getAttribute(addJsonPrefix("params"));
            if (attrCount == 1 && paramsAttr != null) {
                return true;
            }
            if (attrCount == 2
                    && paramsAttr != null
                    && typeAttr != null
                    && (typeAttr.getValue().compareToIgnoreCase(JSONTypes.STRING) == 0
                            || typeAttr.getValue().compareToIgnoreCase(JSONTypes.FUNCTION) == 0)) {
                return true;
            }
        }
        return false;
    }

    private boolean isNullObject(Element element) {
        if (element.getChildCount() == 0) {
            if (element.getAttributeCount() == 0) {
                return true;
            } else if (element.getAttribute(addJsonPrefix("null")) != null) {
                return true;
            } else if (element.getAttributeCount() == 1
                    && (element.getAttribute(addJsonPrefix("class")) != null
                            || element.getAttribute(addJsonPrefix("type")) != null)) {
                return true;
            } else if (element.getAttributeCount() == 2
                    && (element.getAttribute(addJsonPrefix("class")) != null
                            && element.getAttribute(addJsonPrefix("type")) != null)) {
                return true;
            }
        }
        if (skipWhitespace && element.getChildCount() == 1 && element.getChild(0) instanceof Text) {
            return true;
        }
        return false;
    }

    private boolean isObject(Element element, boolean isTopLevel) {
        boolean isObject = false;
        if (!isArray(element, isTopLevel) && !isFunction(element)) {
            if (hasNamespaces(element)) {
                return true;
            }

            int attributeCount = element.getAttributeCount();
            if (attributeCount > 0) {
                int attrs = element.getAttribute(addJsonPrefix("null")) == null ? 0 : 1;
                attrs += element.getAttribute(addJsonPrefix("class")) == null ? 0 : 1;
                attrs += element.getAttribute(addJsonPrefix("type")) == null ? 0 : 1;
                switch (attributeCount) {
                    case 1:
                        if (attrs == 0) {
                            return true;
                        }
                        break;
                    case 2:
                        if (attrs < 2) {
                            return true;
                        }
                        break;
                    case 3:
                        if (attrs < 3) {
                            return true;
                        }
                        break;
                    default:
                        return true;
                }
            }

            int childCount = element.getChildCount();
            if (childCount == 1 && element.getChild(0) instanceof Text) {
                return isTopLevel;
            }

            isObject = true;
        }
        return isObject;
    }

    private Element newElement(String name) {
        if (name.indexOf(':') != -1) {
            namespaceLenient = true;
        }
        return namespaceLenient ? new CustomElement(name) : new Element(name);
    }

    private JSON processArrayElement(Element element, String defaultType) {
        JSONArray jsonArray = new JSONArray();
        // process children (including text)
        int childCount = element.getChildCount();
        for (int i = 0; i < childCount; i++) {
            Node child = element.getChild(i);
            if (child instanceof Text) {
                Text text = (Text) child;
                String stripped = text.getValue() != null ? text.getValue().strip() : null;
                if (stripped != null && !stripped.isBlank()) {
                    jsonArray.element(text.getValue());
                }
            } else if (child instanceof Element) {
                setValue(jsonArray, (Element) child, defaultType);
            }
        }
        return jsonArray;
    }

    private Object processElement(Element element, String type) {
        if (isNullObject(element)) {
            return JSONNull.getInstance();
        } else if (isArray(element, false)) {
            return processArrayElement(element, type);
        } else if (isObject(element, false)) {
            return processObjectElement(element, type);
        } else {
            return trimSpaceFromValue(element.getValue());
        }
    }

    private Element processJSONArray(JSONArray array, Element root, String[] expandableProperties) {
        int l = array.size();
        for (int i = 0; i < l; i++) {
            Object value = array.get(i);
            Element element = processJSONValue(value, root, null, expandableProperties);
            root.appendChild(element);
        }
        return root;
    }

    private Element processJSONObject(
            JSONObject jsonObject, Element root, String[] expandableProperties, boolean isRoot) {
        if (jsonObject.isNullObject()) {
            root.addAttribute(new Attribute(addJsonPrefix("null"), "true"));
            return root;
        } else if (jsonObject.isEmpty()) {
            return root;
        }

        if (isRoot) {
            if (!rootNamespace.isEmpty()) {
                setNamespaceLenient(true);
                for (Iterator entries = rootNamespace.entrySet().iterator(); entries.hasNext(); ) {
                    Map.Entry entry = (Map.Entry) entries.next();
                    String prefix = (String) entry.getKey();
                    String uri = (String) entry.getValue();
                    if (prefix == null || prefix.isBlank()) {
                        root.setNamespaceURI(uri);
                    } else {
                        root.addNamespaceDeclaration(prefix, uri);
                    }
                }
            }
        }

        addNameSpaceToElement(root);

        Object[] names = jsonObject.names().toArray();
        Arrays.sort(names);
        Element element = null;
        for (int i = 0; i < names.length; i++) {
            String name = (String) names[i];
            Object value = jsonObject.get(name);
            if (name.startsWith("@xmlns")) {
                setNamespaceLenient(true);
                int colon = name.indexOf(':');
                if (colon == -1) {
                    // do not override if already defined by nameSpaceMaps
                    if (root.getNamespaceURI() == null || root.getNamespaceURI().isBlank()) {
                        root.setNamespaceURI(String.valueOf(value));
                    }
                } else {
                    String prefix = name.substring(colon + 1);
                    if (root.getNamespaceURI(prefix) == null
                            || root.getNamespaceURI(prefix).isBlank()) {
                        root.addNamespaceDeclaration(prefix, String.valueOf(value));
                    }
                }
            } else if (name.startsWith("@")) {
                root.addAttribute(new Attribute(name.substring(1), String.valueOf(value)));
            } else if (name.equals("#text")) {
                if (value instanceof JSONArray) {
                    root.appendChild(((JSONArray) value).join("", true));
                } else {
                    root.appendChild(String.valueOf(value));
                }
            } else if (value instanceof JSONArray
                    && (((JSONArray) value).isExpandElements() || ArrayUtils.contains(expandableProperties, name))) {
                JSONArray array = (JSONArray) value;
                int l = array.size();
                for (int j = 0; j < l; j++) {
                    Object item = array.get(j);
                    element = newElement(name);
                    if (item instanceof JSONObject) {
                        element = processJSONValue((JSONObject) item, root, element, expandableProperties);
                    } else if (item instanceof JSONArray) {
                        element = processJSONValue((JSONArray) item, root, element, expandableProperties);
                    } else {
                        element = processJSONValue(item, root, element, expandableProperties);
                    }
                    addNameSpaceToElement(element);
                    root.appendChild(element);
                }
            } else {
                element = newElement(name);
                element = processJSONValue(value, root, element, expandableProperties);
                addNameSpaceToElement(element);
                root.appendChild(element);
            }
        }
        return root;
    }

    private Element processJSONValue(Object value, Element root, Element target, String[] expandableProperties) {
        if (target == null) {
            target = newElement(getElementName());
        }
        if (JSONUtils.isBoolean(value)) {
            if (isTypeHintsEnabled()) {
                target.addAttribute(new Attribute(addJsonPrefix("type"), JSONTypes.BOOLEAN));
            }
            target.appendChild(value.toString());
        } else if (JSONUtils.isNumber(value)) {
            if (isTypeHintsEnabled()) {
                target.addAttribute(new Attribute(addJsonPrefix("type"), JSONTypes.NUMBER));
            }
            target.appendChild(value.toString());
        } else if (JSONUtils.isFunction(value)) {
            if (value instanceof String) {
                value = JSONFunction.parse((String) value);
            }
            JSONFunction func = (JSONFunction) value;
            if (isTypeHintsEnabled()) {
                target.addAttribute(new Attribute(addJsonPrefix("type"), JSONTypes.FUNCTION));
            }
            String params = ArrayUtils.toString(func.getParams());
            params = params.substring(1);
            params = params.substring(0, params.length() - 1);
            target.addAttribute(new Attribute(addJsonPrefix("params"), params));
            target.appendChild(new Text("<![CDATA[" + func.getText() + "]]>"));
        } else if (JSONUtils.isString(value)) {
            if (isTypeHintsEnabled()) {
                target.addAttribute(new Attribute(addJsonPrefix("type"), JSONTypes.STRING));
            }
            target.appendChild(value.toString());
        } else if (value instanceof JSONArray) {
            if (isTypeHintsEnabled()) {
                target.addAttribute(new Attribute(addJsonPrefix("class"), JSONTypes.ARRAY));
            }
            target = processJSONArray((JSONArray) value, target, expandableProperties);
        } else if (value instanceof JSONObject) {
            if (isTypeHintsEnabled()) {
                target.addAttribute(new Attribute(addJsonPrefix("class"), JSONTypes.OBJECT));
            }
            target = processJSONObject((JSONObject) value, target, expandableProperties, false);
        } else if (JSONUtils.isNull(value)) {
            if (isTypeHintsEnabled()) {
                target.addAttribute(new Attribute(addJsonPrefix("class"), JSONTypes.OBJECT));
            }
            target.addAttribute(new Attribute(addJsonPrefix("null"), "true"));
        }
        return target;
    }

    private JSON processObjectElement(Element element, String defaultType) {
        if (isNullObject(element)) {
            return JSONNull.getInstance();
        }
        JSONObject jsonObject = new JSONObject();

        if (!skipNamespaces) {
            for (int j = 0; j < element.getNamespaceDeclarationCount(); j++) {
                String prefix = element.getNamespacePrefix(j);
                String uri = element.getNamespaceURI(prefix);
                if (uri == null || uri.isBlank()) {
                    continue;
                }
                if (prefix != null && !prefix.isBlank()) {
                    prefix = ":" + prefix;
                }
                setOrAccumulate(jsonObject, "@xmlns" + prefix, trimSpaceFromValue(uri));
            }
        }

        // process attributes first
        int attrCount = element.getAttributeCount();
        for (int i = 0; i < attrCount; i++) {
            Attribute attr = element.getAttribute(i);
            String attrname = attr.getQualifiedName();
            if (isTypeHintsEnabled()
                    && (addJsonPrefix("class").compareToIgnoreCase(attrname) == 0
                            || addJsonPrefix("type").compareToIgnoreCase(attrname) == 0)) {
                continue;
            }
            String attrvalue = attr.getValue();
            setOrAccumulate(jsonObject, "@" + removeNamespacePrefix(attrname), trimSpaceFromValue(attrvalue));
        }

        // process children (including text)
        int childCount = element.getChildCount();
        for (int i = 0; i < childCount; i++) {
            Node child = element.getChild(i);
            if (child instanceof Text) {
                Text text = (Text) child;
                String stripped = text.getValue() != null ? text.getValue().strip() : null;
                if (stripped != null && !stripped.isBlank()) {
                    setOrAccumulate(jsonObject, "#text", trimSpaceFromValue(text.getValue()));
                }
            } else if (child instanceof Element) {
                setValue(jsonObject, (Element) child, defaultType);
            }
        }

        return jsonObject;
    }

    private String removeNamespacePrefix(String name) {
        if (isRemoveNamespacePrefixFromElements()) {
            int colon = name.indexOf(':');
            return colon != -1 ? name.substring(colon + 1) : name;
        }
        return name;
    }

    private void setOrAccumulate(JSONObject jsonObject, String key, Object value) {
        if (jsonObject.has(key)) {
            jsonObject.accumulate(key, value);
            Object val = jsonObject.get(key);
            if (val instanceof JSONArray) {
                ((JSONArray) val).setExpandElements(true);
            }
        } else {
            jsonObject.element(key, value);
        }
    }

    private void setValue(JSONArray jsonArray, Element element, String defaultType) {
        String clazz = getClass(element);
        String type = getType(element);
        type = (type == null) ? defaultType : type;

        if (hasNamespaces(element) && !skipNamespaces) {
            jsonArray.element(simplifyValue(null, processElement(element, type)));
            return;
        } else if (element.getAttributeCount() > 0) {
            if (isFunction(element)) {
                Attribute paramsAttribute = element.getAttribute(addJsonPrefix("params"));
                String[] params = null;
                String text = element.getValue();
                params = StringUtils.split(paramsAttribute.getValue(), ",");
                jsonArray.element(new JSONFunction(params, text));
                return;
            } else {
                jsonArray.element(simplifyValue(null, processElement(element, type)));
                return;
            }
        }

        boolean classProcessed = false;
        if (clazz != null) {
            if (clazz.compareToIgnoreCase(JSONTypes.ARRAY) == 0) {
                jsonArray.element(processArrayElement(element, type));
                classProcessed = true;
            } else if (clazz.compareToIgnoreCase(JSONTypes.OBJECT) == 0) {
                jsonArray.element(simplifyValue(null, processObjectElement(element, type)));
                classProcessed = true;
            }
        }
        if (!classProcessed) {
            if (type.compareToIgnoreCase(JSONTypes.BOOLEAN) == 0) {
                jsonArray.element(Boolean.valueOf(element.getValue()));
            } else if (type.compareToIgnoreCase(JSONTypes.NUMBER) == 0) {
                // try integer first
                try {
                    jsonArray.element(Integer.valueOf(element.getValue()));
                } catch (NumberFormatException e) {
                    jsonArray.element(Double.valueOf(element.getValue()));
                }
            } else if (type.compareToIgnoreCase(JSONTypes.INTEGER) == 0) {
                jsonArray.element(Integer.valueOf(element.getValue()));
            } else if (type.compareToIgnoreCase(JSONTypes.FLOAT) == 0) {
                jsonArray.element(Double.valueOf(element.getValue()));
            } else if (type.compareToIgnoreCase(JSONTypes.FUNCTION) == 0) {
                String[] params = null;
                String text = element.getValue();
                Attribute paramsAttribute = element.getAttribute(addJsonPrefix("params"));
                if (paramsAttribute != null) {
                    params = StringUtils.split(paramsAttribute.getValue(), ",");
                }
                jsonArray.element(new JSONFunction(params, text));
            } else if (type.compareToIgnoreCase(JSONTypes.STRING) == 0) {
                // see if by any chance has a 'params' attribute
                Attribute paramsAttribute = element.getAttribute(addJsonPrefix("params"));
                if (paramsAttribute != null) {
                    String[] params = null;
                    String text = element.getValue();
                    params = StringUtils.split(paramsAttribute.getValue(), ",");
                    jsonArray.element(new JSONFunction(params, text));
                } else {
                    if (isArray(element, false)) {
                        jsonArray.element(processArrayElement(element, defaultType));
                    } else if (isObject(element, false)) {
                        jsonArray.element(simplifyValue(null, processObjectElement(element, defaultType)));
                    } else {
                        jsonArray.element(trimSpaceFromValue(element.getValue()));
                    }
                }
            }
        }
    }

    private void setValue(JSONObject jsonObject, Element element, String defaultType) {
        String clazz = getClass(element);
        String type = getType(element);
        type = (type == null) ? defaultType : type;

        String key = removeNamespacePrefix(element.getQualifiedName());
        if (hasNamespaces(element) && !skipNamespaces) {
            setOrAccumulate(jsonObject, key, simplifyValue(jsonObject, processElement(element, type)));
            return;
        } else if (element.getAttributeCount() > 0) {
            if (isFunction(element)) {
                Attribute paramsAttribute = element.getAttribute(addJsonPrefix("params"));
                String text = element.getValue();
                String[] params = StringUtils.split(paramsAttribute.getValue(), ",");
                setOrAccumulate(jsonObject, key, new JSONFunction(params, text));
                return;
            } /*else{
                 setOrAccumulate( jsonObject, key, simplifyValue( jsonObject, processElement( element,
                       type ) ) );
                 return;
              }*/
        }

        boolean classProcessed = false;
        if (clazz != null) {
            if (clazz.compareToIgnoreCase(JSONTypes.ARRAY) == 0) {
                setOrAccumulate(jsonObject, key, processArrayElement(element, type));
                classProcessed = true;
            } else if (clazz.compareToIgnoreCase(JSONTypes.OBJECT) == 0) {
                setOrAccumulate(jsonObject, key, simplifyValue(jsonObject, processObjectElement(element, type)));
                classProcessed = true;
            }
        }
        if (!classProcessed) {
            if (type.compareToIgnoreCase(JSONTypes.BOOLEAN) == 0) {
                setOrAccumulate(jsonObject, key, Boolean.valueOf(element.getValue()));
            } else if (type.compareToIgnoreCase(JSONTypes.NUMBER) == 0) {
                // try integer first
                try {
                    setOrAccumulate(jsonObject, key, Integer.valueOf(element.getValue()));
                } catch (NumberFormatException e) {
                    setOrAccumulate(jsonObject, key, Double.valueOf(element.getValue()));
                }
            } else if (type.compareToIgnoreCase(JSONTypes.INTEGER) == 0) {
                setOrAccumulate(jsonObject, key, Integer.valueOf(element.getValue()));
            } else if (type.compareToIgnoreCase(JSONTypes.FLOAT) == 0) {
                setOrAccumulate(jsonObject, key, Double.valueOf(element.getValue()));
            } else if (type.compareToIgnoreCase(JSONTypes.FUNCTION) == 0) {
                String[] params = null;
                String text = element.getValue();
                Attribute paramsAttribute = element.getAttribute(addJsonPrefix("params"));
                if (paramsAttribute != null) {
                    params = StringUtils.split(paramsAttribute.getValue(), ",");
                }
                setOrAccumulate(jsonObject, key, new JSONFunction(params, text));
            } else if (type.compareToIgnoreCase(JSONTypes.STRING) == 0) {
                // see if by any chance has a 'params' attribute
                Attribute paramsAttribute = element.getAttribute(addJsonPrefix("params"));
                if (paramsAttribute != null) {
                    String[] params = null;
                    String text = element.getValue();
                    params = StringUtils.split(paramsAttribute.getValue(), ",");
                    setOrAccumulate(jsonObject, key, new JSONFunction(params, text));
                } else {
                    if (isArray(element, false)) {
                        setOrAccumulate(jsonObject, key, processArrayElement(element, defaultType));
                    } else if (isObject(element, false)) {
                        setOrAccumulate(
                                jsonObject, key, simplifyValue(jsonObject, processObjectElement(element, defaultType)));
                    } else {
                        setOrAccumulate(jsonObject, key, trimSpaceFromValue(element.getValue()));
                    }
                }
            }
        }
    }

    private Object simplifyValue(JSONObject parent, Object json) {
        if (json instanceof JSONObject) {
            JSONObject object = (JSONObject) json;
            if (parent != null) {
                // remove all duplicated @xmlns from child
                for (Iterator entries = parent.entrySet().iterator(); entries.hasNext(); ) {
                    Map.Entry entry = (Map.Entry) entries.next();
                    String key = (String) entry.getKey();
                    Object value = entry.getValue();
                    if (key.startsWith("@xmlns") && value.equals(object.opt(key))) {
                        object.remove(key);
                    }
                }
            }
            if (object.size() == 1 && object.has("#text")) {
                return object.get("#text");
            }
        }
        return json;
    }

    private String trimSpaceFromValue(String value) {
        if (isTrimSpaces()) {
            return value.trim();
        }
        return value;
    }

    private String writeDocument(Document doc, String encoding) {
        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        try {
            XomSerializer serializer = (encoding == null) ? new XomSerializer(baos) : new XomSerializer(baos, encoding);
            serializer.write(doc);
            encoding = serializer.getEncoding();
        } catch (IOException ioe) {
            throw new JSONException(ioe);
        }

        String str = null;
        try {
            str = baos.toString(encoding);
        } catch (UnsupportedEncodingException uee) {
            throw new JSONException(uee);
        }
        return str;
    }

    private static class CustomElement extends Element {
        private static String getName(String name) {
            int colon = name.indexOf(':');
            if (colon != -1) {
                return name.substring(colon + 1);
            }
            return name;
        }

        private static String getPrefix(String name) {
            int colon = name.indexOf(':');
            if (colon != -1) {
                return name.substring(0, colon);
            }
            return "";
        }

        private String prefix;

        public CustomElement(String name) {
            super(CustomElement.getName(name));
            prefix = CustomElement.getPrefix(name);
        }

        public final String getQName() {
            if (prefix.length() == 0) {
                return getLocalName();
            } else {
                return prefix + ":" + getLocalName();
            }
        }
    }

    private class XomSerializer extends Serializer {
        public XomSerializer(OutputStream out) {
            super(out);
        }

        public XomSerializer(OutputStream out, String encoding) throws UnsupportedEncodingException {
            super(out, encoding);
        }

        @Override
        protected void write(Text text) throws IOException {
            String value = text.getValue();
            if (value.startsWith("<![CDATA[") && value.endsWith("]]>")) {
                value = value.substring(9);
                value = value.substring(0, value.length() - 3);
                writeRaw("<![CDATA[");
                writeRaw(value);
                writeRaw("]]>");
            } else {
                super.write(text);
            }
        }

        @Override
        protected void writeEmptyElementTag(Element element) throws IOException {
            if (element instanceof CustomElement && isNamespaceLenient()) {
                writeTagBeginning((CustomElement) element);
                writeRaw("/>");
            } else {
                super.writeEmptyElementTag(element);
            }
        }

        @Override
        protected void writeEndTag(Element element) throws IOException {
            if (element instanceof CustomElement && isNamespaceLenient()) {
                writeRaw("</");
                writeRaw(((CustomElement) element).getQName());
                writeRaw(">");
            } else {
                super.writeEndTag(element);
            }
        }

        @Override
        protected void writeNamespaceDeclaration(String prefix, String uri) throws IOException {
            if (uri != null && !uri.isBlank()) {
                super.writeNamespaceDeclaration(prefix, uri);
            }
        }

        @Override
        protected void writeStartTag(Element element) throws IOException {
            if (element instanceof CustomElement && isNamespaceLenient()) {
                writeTagBeginning((CustomElement) element);
                writeRaw(">");
            } else {
                super.writeStartTag(element);
            }
        }

        private void writeTagBeginning(CustomElement element) throws IOException {
            writeRaw("<");
            writeRaw(element.getQName());
            writeAttributes(element);
            writeNamespaceDeclarations(element);
        }
    }
}
