/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.jackrabbit.core.util;

import org.w3c.dom.Attr;
import org.w3c.dom.CharacterData;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.NamedNodeMap;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import org.xml.sax.EntityResolver;
import org.xml.sax.InputSource;

import javax.xml.XMLConstants;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import java.io.IOException;
import java.io.InputStream;
import java.io.StringReader;
import java.util.Properties;

/**
 * Document walker class. This class provides an intuitive
 * interface for traversing a parsed DOM document.
 */
public final class DOMWalker {

    /** Static factory for creating stream to DOM transformers. */
    private static final DocumentBuilderFactory factory = createFactory();

    private static DocumentBuilderFactory createFactory() {
        DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
        factory.setIgnoringComments(false);
        factory.setIgnoringElementContentWhitespace(true);
        factory.setXIncludeAware(false);

        // Prevent XXE attacks by disabling external entity processing
        factory.setExpandEntityReferences(false);

        String feature = null;

        try {
            feature = XMLConstants.FEATURE_SECURE_PROCESSING;
            factory.setFeature(feature, true);
            feature = "http://apache.org/xml/features/disallow-doctype-decl";
            factory.setFeature(feature, true);
            feature = "http://apache.org/xml/features/nonvalidating/load-external-dtd";
            factory.setFeature(feature, false);
            feature = "http://xml.org/sax/features/external-general-entities";
            factory.setFeature(feature, false);
            feature = "http://xml.org/sax/features/external-parameter-entities";
            factory.setFeature(feature, false);
        } catch (Exception ex) {
            // abort if secure processing is not supported
            throw new IllegalStateException("Secure processing feature '" + feature + "' not supported by the DocumentBuilderFactory: " + factory.getClass().getName(), ex);
        }
        return factory;
    }

    /** The DOM document being traversed by this walker. */
    private final Document document;

    /** The current element. */
    private Element current;

    /**
     * Creates a walker for traversing a DOM document read from the given
     * input stream. The root element of the document is set as the current
     * element.
     *
     * @param xml XML input stream
     * @throws IOException if a document cannot be read from the stream
     */
    public DOMWalker(InputStream xml) throws IOException {
        try {
            DocumentBuilder builder = factory.newDocumentBuilder();
            // defense in depth: entity resolver that will break any document on purpose
            EntityResolver stopMe = (publicId, systemId) -> new InputSource(
                    new StringReader("<preventing read of: " + publicId + " " + systemId + ">"));
            builder.setEntityResolver(stopMe);
            document = builder.parse(xml);
            current = document.getDocumentElement();
        } catch (IOException e) {
            throw e;
        } catch (Exception e) {
            throw (IOException) new IOException(e.getMessage()).initCause(e);
        }
    }

    /**
     * Returns the namespace mappings defined in the current element.
     * The returned property set contains the prefix to namespace
     * mappings specified by the <code>xmlns</code> attributes of the
     * current element.
     *
     * @return prefix to namespace mappings of the current element
     */
    public Properties getNamespaces() {
        Properties namespaces = new Properties();
        NamedNodeMap attributes = current.getAttributes();
        for (int i = 0; i < attributes.getLength(); i++) {
            Attr attribute = (Attr) attributes.item(i);
            if (attribute.getName().startsWith("xmlns:")) {
                namespaces.setProperty(
                        attribute.getName().substring(6), attribute.getValue());
            }
        }
        return namespaces;
    }

    /**
     * Returns the name of the current element.
     *
     * @return element name
     */
    public String getName() {
        return current.getNodeName();
    }

    /**
     * Returns the value of the named attribute of the current element.
     *
     * @param name attribute name
     * @return attribute value, or <code>null</code> if not found
     */
    public String getAttribute(String name) {
        Attr attribute = current.getAttributeNode(name);
        if (attribute != null) {
            return attribute.getValue();
        } else {
            return null;
        }
    }

    /**
     * Returns the text content of the current element.
     *
     * @return text content
     */
    public String getContent() {
        StringBuilder content = new StringBuilder();

        NodeList nodes = current.getChildNodes();
        for (int i = 0; i < nodes.getLength(); i++) {
            Node node = nodes.item(i);
            if (node.getNodeType() == Node.TEXT_NODE) {
                content.append(((CharacterData) node).getData());
            }
        }

        return content.toString();
    }

    /**
     * Enters the named child element. If the named child element is
     * found, then it is made the current element and <code>true</code>
     * is returned. Otherwise the current element is not changed and
     * <code>false</code> is returned.
     * <p>
     * The standard call sequence for this method is show below.
     * <pre>
     *     DOMWalker walker = ...;
     *     if (walker.enterElement("...")) {
     *         ...;
     *         walker.leaveElement();
     *     }
     * </pre>
     *
     * @param name child element name
     * @return <code>true</code> if the element was entered,
     *         <code>false</code> otherwise
     */
    public boolean enterElement(String name) {
        NodeList children = current.getChildNodes();
        for (int i = 0; i < children.getLength(); i++) {
            Node child = children.item(i);
            if (child.getNodeType() == Node.ELEMENT_NODE
                    && name.equals(child.getNodeName())) {
                current = (Element) child;
                return true;
            }
        }
        return false;
    }

    /**
     * Leaves the current element. The parent element is set as the new
     * current element.
     *
     * @see #enterElement(String)
     */
    public void leaveElement() {
        current = (Element) current.getParentNode();
    }

    /**
     * Iterates through the named child elements over multiple calls.
     * This method makes it possible to use the following code to
     * walk through all the child elements with the given name.
     * <pre>
     *     DOMWalker walker = ...;
     *     while (walker.iterateElements("...")) {
     *         ...;
     *     }
     * </pre>
     * <p>
     * <strong>WARNING:</strong> This method should only be used when
     * <code>walker.getName()</code> does not equal <code>name</code> when
     * the while loop is started. Otherwise the walker will not be positioned
     * at the same node when the while loop ends.
     *
     * @param name name of the iterated elements
     * @return <code>true</code> if another iterated element was entered, or
     *         <code>false</code> if no more iterated elements were found
     *         and the original element is restored as the current element
     */
    public boolean iterateElements(String name) {
        Node next;
        if (name.equals(current.getNodeName())) {
            next = current.getNextSibling();
        } else {
            next = current.getFirstChild();
        }

        while (next != null) {
            if (next.getNodeType() == Node.ELEMENT_NODE
                    && name.equals(next.getNodeName())) {
                current = (Element) next;
                return true;
            } else {
                next = next.getNextSibling();
            }
        }

        if (name.equals(current.getNodeName())) {
            Node parent = current.getParentNode();
            if (parent instanceof Element) {
                current = (Element) parent;
            }
        }
        return false;
    }

}
