/*
 * Copyright @ 2015 Atlassian Pty Ltd
 *
 * 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.jitsi.util.xml;

import static javax.xml.XMLConstants.W3C_XML_SCHEMA_INSTANCE_NS_URI;
import static javax.xml.XMLConstants.W3C_XML_SCHEMA_NS_URI;
import static javax.xml.XMLConstants.XMLNS_ATTRIBUTE_NS_URI;
import static javax.xml.XMLConstants.XML_NS_URI;

import java.io.*;
import java.util.*;

import javax.xml.*;
import javax.xml.parsers.*;
import javax.xml.transform.*;
import javax.xml.transform.dom.*;
import javax.xml.transform.stream.*;

import org.apache.commons.lang3.*;
import org.jitsi.util.*;
import org.jitsi.utils.logging.*;
import org.w3c.dom.*;
import org.xml.sax.*;

/**
 * Common XML Tasks
 *
 * @author Emil Ivov
 * @author Damian Minkov
 */
public class XMLUtils
{
    /**
     * The string identifying the <tt>DocumentBuilderFactory</tt>feature which
     * controls whether inclusion of external general entities is allowed.
     * See
     * <a href="http://xerces.apache.org/xerces-j/features.html#external-general-entities">xerces options</a>
     * and
     * <a href="http://xerces.apache.org/xerces2-j/features.html#external-general-entities">xerces2 options</a>
     */
    private static final String FEATURE_EXTERNAL_GENERAL_ENTITIES
        = "http://xml.org/sax/features/external-general-entities";

    /**
     * The string identifying the <tt>DocumentBuilderFactory</tt>feature which
     * controls whether inclusion of external parameter entities is allowed.
     * See
     * <a href="http://xerces.apache.org/xerces-j/features.html#external-parameter-entities">xerces options</a>
     * and
     * <a href="http://xerces.apache.org/xerces2-j/features.html#external-parameter-entities">xerces2 options</a>
     */
    private static final String FEATURE_EXTERNAL_PARAMETER_ENTITIES
        = "http://xml.org/sax/features/external-parameter-entities";


    /**
     * The string identifying the <tt>DocumentBuilderFactory</tt>feature which
     * controls whether DOCTYPE declaration is allowed.
     * See
     * <a href="http://xerces.apache.org/xerces2-j/features.html#disallow-doctype-decl">xerces2 options</a>
     */
    private static final String FEATURE_DISSALLOW_DOCTYPE
        = "http://apache.org/xml/features/disallow-doctype-decl";

    /**
     * The <tt>Logger</tt> used by the <tt>XMLUtils</tt> class for logging
     * output.
     */
    private static final Logger logger = Logger.getLogger(XMLUtils.class);

    /**
     * Extracts from node the attribute with the specified name.
     * @param node the node whose attribute we'd like to extract.
     * @param name the name of the attribute to extract.
     * @return a String containing the trimmed value of the attribute or null
     * if no such attribute exists
     */
    public static String getAttribute(Node node, String name)
    {
        if (node == null)
            return null;

        Node attribute = node.getAttributes().getNamedItem(name);
        return (attribute == null)
                    ? null
                    : attribute.getNodeValue().trim();
    }

    /**
     * Extracts the String content of a TXT element.
     *
     * @param parentNode the node containing the data that we'd like to get.
     * @return the string contained by the node or null if none existed.
     */
    public static String getText(Element parentNode)
    {
        Text text = getTextNode(parentNode);

        return (text == null) ? null : text.getData();
    }

    /**
     * Sets data to be the TEXT content of element
     *
     * @param parentNode the parent element.
     * @param data the data to set.
     */
    public static void setText(Element parentNode, String data)
    {
        if(data == null)
            return;

        Text txt = getTextNode(parentNode);

        if (txt != null)
            txt.setData(data);
        else
        {
            txt = parentNode.getOwnerDocument().createTextNode(data);
            parentNode.appendChild(txt);
        }
    }

    /**
     * Sets data to be the CDATA content of element
     *
     * @param element the parent element.
     * @param data the data to set.
     */
    public static void setCData(Element element, String data)
    {
        if(data == null)
            return;

        CDATASection txt = getCDataNode(element);
        if (txt != null)
            txt.setData(data);
        else
        {
            txt = element.getOwnerDocument().createCDATASection(data);
            element.appendChild(txt);
        }
    }

    /**
     * Extract the CDATA content of the specified element.
     * @param element the element whose data we need
     * @return a String containing the CDATA value of element.
     */
    public static String getCData(Element element)
    {
        CDATASection text = getCDataNode(element);

        return (text == null) ? null : text.getData().trim();
    }


    /**
     * Returns element's CDATA child node (if it has one).
     * @param element the element whose CDATA we need to get.
     * @return a CDATASection object containing the specified element's CDATA
     * content
     */
    public static CDATASection getCDataNode(Element element)
    {
        return (CDATASection)getChildByType(element,
                                            Node.CDATA_SECTION_NODE);
    }

    /**
     * Returns element's TEXT child node (if it has one).
     * @param element the element whose TEXT we need to get.
     * @return a <tt>Text</tt> object containing the specified element's
     * text content.
     */
    public static Text getTextNode(Element element)
    {
        return (Text)getChildByType(element, Node.TEXT_NODE);
    }

    /**
     * Returns first of the <tt>element</tt>'s child nodes that is of type
     * <tt>nodeType</tt>.
     * @param element the element whose child we need.
     * @param nodeType the type of the child we need.
     * @return a child of the specified <tt>nodeType</tt> or null if none
     * was found.
     */
    public static Node getChildByType(Element element, short nodeType)
    {
        if (element == null)
            return null;

        NodeList nodes = element.getChildNodes();
        if (nodes == null || nodes.getLength() < 1)
            return null;

        Node node;
        String data;
        for (int i = 0; i < nodes.getLength(); i++)
        {
            node = nodes.item(i);
            short type = node.getNodeType();
            if (type == nodeType)
            {
                if (type == Node.TEXT_NODE ||
                    type == Node.CDATA_SECTION_NODE)
                {
                    data = ( (Text) node).getData();
                    if (data == null || data.trim().length() < 1)
                        continue;
                }

                return node;
            }
        }

        return null;
    }

    /**
     * Writes the specified document to the given file adding indentatation.
     * The default encoding is UTF-8.
     *
     * @param out the output File
     * @param document the document to write
     *
     * @throws java.io.IOException in case a TransformerException is thrown by
     * the underlying Transformer.
     */
    public static void writeXML(Document document, File out)
        throws java.io.IOException
    {
        FileOutputStream fos = new FileOutputStream(out);
//        indentedWriteXML(document, fos);
        writeXML(document
                 , new StreamResult(new OutputStreamWriter(fos, "UTF-8"))
                 , null
                 , null);
        fos.close();
    }

    /**
     * Writes the specified document to the given file adding indentatation.
     * The default encoding is UTF-8.
     *
     * @param writer the writer to use when writing the File
     * @param document the document to write
     *
     * @throws java.io.IOException in case a TransformerException is thrown by
     * the underlying Transformer.
     */
    public static void writeXML(Document document,
                                Writer   writer)
        throws java.io.IOException
    {
        writeXML(document, new StreamResult(writer), null, null);
        writer.close();
    }

    /**
     * Writes the specified document to the given file adding indentatation.
     * The default encoding is UTF-8.
     *
     * @param streamResult the streamResult object where the document should be
     * written
     * @param document the document to write
     * @param doctypeSystem the doctype system of the xml document that we should
     * record in the file or null if none is specified.
     * @param doctypePublic the public identifier to be used in the document
     * type declaration.
     *
     * @throws java.io.IOException in case a TransformerException is thrown by
     * the underlying Transformer.
     */
    public static void writeXML(Document document,
                                StreamResult streamResult,
                                String   doctypeSystem,
                                String   doctypePublic)
        throws java.io.IOException
    {
        try
        {
           DOMSource domSource = new DOMSource(document);
           TransformerFactory tf = TransformerFactory.newInstance();

           // not working for jdk 1.4
           try
           {
                tf.setAttribute("indent-number", 4);
           }catch(Exception e){}

           Transformer serializer = tf.newTransformer();
           if(doctypeSystem != null)
                   serializer.setOutputProperty(OutputKeys.DOCTYPE_SYSTEM,
                                                doctypeSystem);
            if(doctypePublic != null)
                   serializer.setOutputProperty(OutputKeys.DOCTYPE_PUBLIC,
                                                doctypePublic);
           // not working for jdk 1.5
           serializer.setOutputProperty("{http://xml.apache.org/xalan}indent-amount", "4");
           serializer.setOutputProperty(OutputKeys.ENCODING, "UTF-8");
           serializer.setOutputProperty(OutputKeys.INDENT, "yes");
           serializer.transform(domSource, streamResult);
       }
        catch (TransformerException ex) {
            logger.error("Error saving configuration file", ex);
            throw new java.io.IOException(
                "Failed to write the configuration file: "
                + ex.getMessageAndLocation());
        }
        catch (IllegalArgumentException ex) {
            //this one is thrown by the setOutputProperty or in other words -
            //shoudln't happen. so let's just log it down in case ...
            logger.error("Error saving configuration file", ex);
        }
    }

    /**
     * A simple implementation of XML writing that also allows for indentation.
     * @param doc the Document that we will be writing.
     * @param out an OutputStream to write the document through.
     */
    public static void indentedWriteXML(Document doc, OutputStream out)
    {
        if (out != null)
        {
            try
            {
                Writer wri = new OutputStreamWriter(out, "UTF-8");
//                wri.write("<?xml version=\"1.0\" encoding=\"UTF-8\" ?>"+lSep);
//                (new DOMElementWriter()).write(rootElement, wri, 0, "  ");
//                wri.flush();
//                wri.close();
                writeXML(doc
                 , new StreamResult(wri)
                 , null
                 , null);
                out.close();
            }
            catch (IOException exc)
            {
                throw new RuntimeException("Unable to write xml", exc);
            }
        }
    }


    /**
     * Whenever you'd need to print a configuration node and/or its children.
     *
     * @param root the root node to print.
     * @param out the print stream that should be used to outpu
     * @param recurse boolean
     * @param prefix String
     */
    public static void printChildElements(Element root,
                                          PrintStream out,
                                          boolean recurse,
                                          String prefix)
    {
        out.print(prefix + "<" + root.getNodeName());
        NamedNodeMap attrs = root.getAttributes();
        Node node;
        for(int i = 0; i < attrs.getLength(); i++)
        {
            node = attrs.item(i);
            out.print(" " + node.getNodeName() + "=\""
                      + node.getNodeValue() + "\"");
        }
        out.println(">");

        String data = getText(root);
        if(data != null && data.trim().length() > 0)
            out.println(prefix + "\t" + data);

        data = getCData(root);
        if(data != null && data.trim().length() > 0)
            out.println(prefix + "\t<![CDATA[" + data + "]]>");

        NodeList nodes = root.getChildNodes();
        for(int i = 0; i < nodes.getLength(); i++)
        {
            node = nodes.item(i);
            if(node.getNodeType() == Node.ELEMENT_NODE)
            {
                if(recurse)
                    printChildElements((Element)node, out, recurse, prefix
                                       + "\t");
                else
                    out.println(prefix + node.getNodeName());
            }
        }

        out.println(prefix + "</" + root.getNodeName() + ">");
    }

    /**
     * Returns the child element with the specified tagName for the specified
     * parent element.
     * @param parent The parent whose child we're looking for.
     * @param tagName the name of the child to find
     * @return The child with the specified name or null if no such child was
     *         found.
     * @throws NullPointerException if parent or tagName are null
     */
    public static Element findChild(Element parent, String tagName)
    {
        if(parent == null || tagName == null)
            throw new NullPointerException("Parent or tagname were null! "
                + "parent = " + parent + "; tagName = " + tagName);

        NodeList nodes = parent.getChildNodes();
        Node node;
        int len = nodes.getLength();
        for(int i = 0; i < len; i++)
        {
            node = nodes.item(i);
            if(node.getNodeType() == Node.ELEMENT_NODE
               && ((Element)node).getNodeName().equals(tagName))
                return (Element)node;
        }

        return null;
    }

    /**
     * Returns the children elements with the specified tagName for the
     * specified parent element.
     *
     * @param parent The parent whose children we're looking for.
     * @param tagName the name of the child to find
     * @return List of the children with the specified name
     * @throws NullPointerException if parent or tagName are null
     */
    public static List<Element> findChildren(Element parent, String tagName)
    {
        if (parent == null || tagName == null)
            throw new NullPointerException("Parent or tagname were null! "
                + "parent = " + parent + "; tagName = " + tagName);

        List<Element> result = new ArrayList<Element>();
        NodeList nodes = parent.getChildNodes();
        Node node;
        int len = nodes.getLength();
        for (int i = 0; i < len; i++)
        {
            node = nodes.item(i);
            if (node.getNodeType() == Node.ELEMENT_NODE)
            {
                Element element = (Element) node;
                if (element.getNodeName().equals(tagName))
                    result.add(element);
            }
        }

        return result;
    }

    /**
     * Looks through all child elements of the specified root (recursively)
     * and returns the first element that corresponds to all parameters.
     *
     * @param root the Element where the search should begin
     * @param tagName the name of the node we're looking for
     * @param keyAttributeName the name of an attribute that the node has to
     * have
     * @param keyAttributeValue the value that attribute must have
     * @return the Element in the tree under root that matches the specified
     * parameters.
     * @throws NullPointerException if any of the arguments is null.
     */
    public static Element locateElement(Element root,
                                        String tagName,
                                        String keyAttributeName,
                                        String keyAttributeValue)
    {
        NodeList nodes = root.getChildNodes();
        int len = nodes.getLength();

        for(int i = 0; i < len; i++)
        {
            Node node = nodes.item(i);

            if(node.getNodeType() != Node.ELEMENT_NODE)
                continue;

            Element element = (Element) node;

            // is this the node we're looking for?
            if(node.getNodeName().equals(tagName))
            {
                String attr = element.getAttribute(keyAttributeName);

                if((attr != null) && attr.equals(keyAttributeValue))
                    return element;
            }

            //look inside.
            Element child
                = locateElement(
                        element,
                        tagName, keyAttributeName, keyAttributeValue);

            if (child != null)
                return child;
        }
        return null;
    }

    /**
     * Looks through all child elements of the specified root (recursively) and
     * returns the elements that corresponds to all parameters.
     *
     * @param root the Element where the search should begin
     * @param tagName the name of the node we're looking for
     * @param keyAttributeName the name of an attribute that the node has to
     *            have
     * @param keyAttributeValue the value that attribute must have
     * @return list of Elements in the tree under root that match the specified
     *         parameters.
     * @throws NullPointerException if any of the arguments is null.
     */
    public static List<Element> locateElements(Element root, String tagName,
        String keyAttributeName, String keyAttributeValue)
    {
        List<Element> result = new ArrayList<Element>();
        NodeList nodes = root.getChildNodes();
        Node node;
        int len = nodes.getLength();
        for (int i = 0; i < len; i++)
        {
            node = nodes.item(i);
            if (node.getNodeType() != Node.ELEMENT_NODE)
                continue;

            // is this the node we're looking for?
            if (node.getNodeName().equals(tagName))
            {
                Element element = (Element) node;
                String attr = element.getAttribute(keyAttributeName);

                if (attr != null && attr.equals(keyAttributeValue))
                    result.add(element);
            }

            // look inside.

            List<Element> childs =
                locateElements((Element) node, tagName, keyAttributeName,
                    keyAttributeValue);

            if (childs != null)
                result.addAll(childs);

        }

        return result;
    }

    /**
     * Indicates whether namespace is one of the standart xml namespace.
     *
     * @param namespace the namespace to analyze.
     * @return true if namespace is one of the standart xml namespace otherwise
     *         false.
     */
    public static boolean isStandartXmlNamespace(String namespace)
    {
        namespace = normalizeNamespace(namespace);
        return normalizeNamespace(XML_NS_URI).equals(namespace)
                || normalizeNamespace(XMLNS_ATTRIBUTE_NS_URI).equals(namespace)
                || normalizeNamespace(W3C_XML_SCHEMA_NS_URI).equals(namespace)
                || normalizeNamespace(W3C_XML_SCHEMA_INSTANCE_NS_URI)
                .equals(namespace);
    }

    /**
     * Gets the node namespace.
     *
     * @param node the <tt>Element</tt> or <tt>Attr</tt> node to analyze.
     * @return the node namespace or null.
     */
    public static String getNamespaceUri(Node node)
    {
        String prefix = node.getPrefix();
        String namespaceUri = node.getNamespaceURI();

        if (StringUtils.isNotBlank(namespaceUri))
            return normalizeNamespace(namespaceUri);
        if (XMLConstants.XMLNS_ATTRIBUTE.equals(node.getNodeName())
                || XMLConstants.XMLNS_ATTRIBUTE.equals(prefix))
            return normalizeNamespace(XMLNS_ATTRIBUTE_NS_URI);

        Element rootElement = node.getOwnerDocument().getDocumentElement();
        Node parentNode = null;

        while (parentNode != rootElement)
        {
            if (parentNode == null)
            {
                if (node.getNodeType() == Node.ATTRIBUTE_NODE)
                {
                    parentNode = ((Attr) node).getOwnerElement();
                    // If attribute doesn't have prefix - it has its parent
                    // namespace
                    if (StringUtils.isBlank(prefix))
                        prefix = parentNode.getPrefix();
                }
                else if (node.getNodeType() == Node.ELEMENT_NODE)
                    parentNode = node.getParentNode();
                else
                    return null;
            }
            else
                parentNode = parentNode.getParentNode();
            String parentPrefix = parentNode.getPrefix();
            String parentNamespaceUri = parentNode.getNamespaceURI();
            if (StringUtils.isBlank(prefix))
            {
                Node xmlnsAttribute =
                        parentNode.getAttributes().getNamedItem("xmlns");
                if (xmlnsAttribute != null)
                    return ((Attr) xmlnsAttribute).getValue();
            }
            else if (Objects.equals(prefix, parentPrefix))
            {
                if (StringUtils.isNotBlank(parentNamespaceUri))
                    return normalizeNamespace(parentNamespaceUri);
            }
        }
        if ("xml".equals(prefix))
            return normalizeNamespace(XML_NS_URI);
        return null;
    }

    /**
     * Normalizes the namespace.
     *
     * @param namespace the namespace to normalize.
     * @return normalized namespace.
     */
    private static String normalizeNamespace(String namespace)
    {
        if (namespace.endsWith("/"))
        {
            return namespace.substring(0, namespace.length() - 1);
        }
        return namespace;
    }

    /**
     * Indicates whether element has any child element.
     *
     * @param element the namespace to analyze.
     * @return true if element has any child element otherwise false.
     */
    public static boolean hasChildElements(Element element)
    {
        NodeList childNodes = element.getChildNodes();
        for (int i = 0; i < childNodes.getLength(); i++)
        {
            Node node = childNodes.item(i);
            if (node.getNodeType() == Node.ELEMENT_NODE)
            {
                return true;
            }
        }
        return false;
    }

    /**
     * Creates W3C Document.
     *
     * @return the W3C Document.
     * @throws Exception is there is some error during operation.
     */
    public static Document createDocument()
            throws Exception
    {
        return createDocument(null);
    }

    /**
     * Creates W3C Document from the xml.
     *
     * @param xml the xml that needs to be converted.
     * @param allowExternalEntities whether parsing of XML external entities
     * and DOCTYPE declarations should be allowed.
     * @return the W3C Document.
     * @throws Exception is there is some error during operation.
     */
    public static Document createDocument(String xml,
                                          boolean allowExternalEntities)
            throws Exception
    {
        DocumentBuilderFactory builderFactory
                = newDocumentBuilderFactory(allowExternalEntities);
        builderFactory.setNamespaceAware(true);

        DocumentBuilder documentBuilder = builderFactory.newDocumentBuilder();
        if (StringUtils.isNotBlank(xml))
        {
            return documentBuilder.parse(new InputSource(new StringReader(xml)));
        }
        else
        {
            return documentBuilder.newDocument();
        }
    }

    /**
     * Creates W3C Document from the xml.
     *
     * @param xml the xml that needs to be converted.
     * @return the W3C Document.
     * @throws Exception is there is some error during operation.
     */
    public static Document createDocument(String xml)
            throws Exception
    {
        return createDocument(xml, false);
    }

    /**
     * Creates XML from W3C Document from the xml.
     *
     * @param document the xml that needs to be converted.
     * @return the XML.
     * @throws Exception is there is some error during operation.
     */
    public static String createXml(Document document)
            throws Exception
    {
        TransformerFactory transformerFactory =
                TransformerFactory.newInstance();
        Transformer transformer = transformerFactory.newTransformer();
        StringWriter stringWriter = new StringWriter();
        StreamResult result = new StreamResult(stringWriter);
        DOMSource source = new DOMSource(document);
        transformer.transform(source, result);
        return stringWriter.toString();
    }

    /**
     * Creates and returns a new <tt>DocumentBuilderFactory</tt> instance, and
     * sets the default set of features.
     * @return the created factory
     * @throws ParserConfigurationException if setting a feature fails.
     */
    public static DocumentBuilderFactory newDocumentBuilderFactory()
            throws ParserConfigurationException
    {
        return newDocumentBuilderFactory(false);
    }

    /**
     * Creates and returns a new <tt>DocumentBuilderFactory</tt> instance, and
     * sets the default set of features.
     *
     * @param allowExternalEntities whether parsing of XML external entities
     * and DOCTYPE declarations should be allowed.
     * @return the created factory
     * @throws ParserConfigurationException if setting a feature fails.
     */
    public static DocumentBuilderFactory newDocumentBuilderFactory(
            boolean allowExternalEntities)
        throws ParserConfigurationException
    {
        DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
        if (!allowExternalEntities)
            disableExternalEntities(factory);
        return factory;
    }

    /**
     * Tries to set the features which disable inclusion of XML external
     * entities and DOCTYPE declarations.
     * @param factory the factory
     * @throws javax.xml.parsers.ParserConfigurationException if setting any
     * of the features fails.
     */
    public static void disableExternalEntities(DocumentBuilderFactory factory)
        throws ParserConfigurationException
    {
        // It seems that currently the android parsers do not support the below
        // features, but also do not support external entities and thus are not
        // vulnerable to the attacks which setting these features aims to
        // prevent. In other words, it is (currently) safe to ignore the
        // exceptions on android.

        try
        {
            factory.setFeature(FEATURE_EXTERNAL_GENERAL_ENTITIES, false);
        }
        catch (ParserConfigurationException pce)
        {
            if (OSUtils.IS_ANDROID)
                logger.warn("Failed to set feature: "
                            + FEATURE_EXTERNAL_GENERAL_ENTITIES);
            else
                throw pce;
        }

        try
        {
            factory.setFeature(FEATURE_EXTERNAL_PARAMETER_ENTITIES, false);
        }
        catch (ParserConfigurationException pce)
        {
            if (OSUtils.IS_ANDROID)
                logger.warn("Failed to set feature: "
                                    + FEATURE_EXTERNAL_PARAMETER_ENTITIES);
            else
                throw pce;
        }

        try
        {
            factory.setFeature(FEATURE_DISSALLOW_DOCTYPE, true);
        }
        catch (ParserConfigurationException pce)
        {
            if (OSUtils.IS_ANDROID)
                logger.warn("Failed to set feature: "
                                    + FEATURE_DISSALLOW_DOCTYPE);
            else
                throw pce;
        }
    }
}
