/*
 * 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.spi.commons.privilege;

import org.apache.jackrabbit.spi.Name;
import org.apache.jackrabbit.spi.NameFactory;
import org.apache.jackrabbit.spi.PrivilegeDefinition;
import org.apache.jackrabbit.spi.commons.name.NameFactoryImpl;
import org.apache.jackrabbit.util.Text;
import org.w3c.dom.Attr;
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 org.xml.sax.SAXException;
import org.xml.sax.helpers.DefaultHandler;

import javax.xml.XMLConstants;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import javax.xml.transform.OutputKeys;
import javax.xml.transform.Result;
import javax.xml.transform.Transformer;
import javax.xml.transform.TransformerFactory;
import javax.xml.transform.dom.DOMSource;
import javax.xml.transform.stream.StreamResult;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.Reader;
import java.io.StringReader;
import java.io.Writer;
import java.rmi.server.ExportException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;

/**
 * The <code>PrivilegeXmlHandler</code> loads and stores privilege definitions from a XML document using the
 * following format:
 * <xmp>
 *  <!DOCTYPE privileges [
 *  <!ELEMENT privileges (privilege)+>
 *  <!ELEMENT privilege (contains)+>
 *  <!ATTLIST privilege abstract (true|false) false>
 *  <!ATTLIST privilege name NMTOKEN #REQUIRED>
 *  <!ELEMENT contains EMPTY>
 *  <!ATTLIST contains name NMTOKEN #REQUIRED>
 * ]>
 * </xmp>
 */
class PrivilegeXmlHandler implements PrivilegeHandler {

    private static final String TEXT_XML = "text/xml";
    private static final String APPLICATION_XML = "application/xml";

    private static final String XML_PRIVILEGES = "privileges";
    private static final String XML_PRIVILEGE = "privilege";
    private static final String XML_CONTAINS = "contains";

    private static final String ATTR_NAME = "name";
    private static final String ATTR_ABSTRACT = "abstract";

    private static final String ATTR_XMLNS = "xmlns:";

    private static final String LICENSE_HEADER = createLicenseHeader();

    private static final NameFactory NAME_FACTORY = NameFactoryImpl.getInstance();

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

    /**
     * Constant for <code>DocumentBuilderFactory</code>.
     */
    private static DocumentBuilderFactory DOCUMENT_BUILDER_FACTORY = createFactory();

    private static DocumentBuilderFactory createFactory() {
        DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
        factory.setNamespaceAware(true);
        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;
    }

    /**
     * Constant for <code>TransformerFactory</code>
     */
    private static TransformerFactory TRANSFORMER_FACTORY = TransformerFactory.newInstance();

    /**
     * Create a new instance.
     */
    PrivilegeXmlHandler() {

    }

    /**
     * Returns <code>true</code> if the specified content type can be handled by this class.
     *
     * @param contentType
     * @return <code>true</code> if the specified content type can be handled
     * by this class; <code>false</code> otherwise.
     */
    static boolean isSupportedContentType(String contentType) {
        return TEXT_XML.equals(contentType) || APPLICATION_XML.equals(contentType);
    }

    //---------------------------------------------------< PrivilegeHandler >---
    /**
     * @see PrivilegeHandler#readDefinitions(java.io.InputStream, java.util.Map)
     */
    public PrivilegeDefinition[] readDefinitions(InputStream in, Map<String, String> namespaces) throws ParseException {
        return readDefinitions(new InputSource(in), namespaces);

    }

    /**
     * @see PrivilegeHandler#readDefinitions(java.io.Reader, java.util.Map)
     */
    public PrivilegeDefinition[] readDefinitions(Reader reader, Map<String, String> namespaces) throws ParseException {
        return readDefinitions(new InputSource(reader), namespaces);
    }

    private PrivilegeDefinition[] readDefinitions(InputSource input, Map<String, String> namespaces) throws ParseException {
        try {
            List<PrivilegeDefinition> defs = new ArrayList<PrivilegeDefinition>();

            DocumentBuilder builder = createDocumentBuilder();
            Document doc = builder.parse(input);
            Element root = doc.getDocumentElement();
            if (!XML_PRIVILEGES.equals(root.getNodeName())) {
                throw new IllegalArgumentException("root element must be named 'privileges'");
            }

            updateNamespaceMapping(root, namespaces);

            NodeList nl = root.getElementsByTagName(XML_PRIVILEGE);
            for (int i = 0; i < nl.getLength(); i++) {
                Node n = nl.item(i);
                PrivilegeDefinition def = parseDefinition(n, namespaces);
                if (def != null) {
                    defs.add(def);
                }
            }
            return defs.toArray(new PrivilegeDefinition[defs.size()]);

        } catch (SAXException e) {
            throw new ParseException(e);
        } catch (IOException e) {
            throw new ParseException(e);
        } catch (ParserConfigurationException e) {
            throw new ParseException(e);
        }
    }

    /**
     * @see PrivilegeHandler#writeDefinitions(java.io.OutputStream, PrivilegeDefinition[], java.util.Map)
     */
    public void writeDefinitions(OutputStream out, PrivilegeDefinition[] definitions, Map<String, String> namespaces) throws IOException {
        writeDefinitions(new StreamResult(out), definitions, namespaces);
    }

    /**
     * @see PrivilegeHandler#writeDefinitions(java.io.Writer, PrivilegeDefinition[], java.util.Map)
     */
    public void writeDefinitions(Writer writer, PrivilegeDefinition[] definitions, Map<String, String> namespaces) throws IOException {
        writeDefinitions(new StreamResult(writer), definitions, namespaces);
    }

    private void writeDefinitions(Result result, PrivilegeDefinition[] definitions, Map<String, String> namespaces) throws IOException {
        try {
            Map<String, String> uriToPrefix = new HashMap<String, String>(namespaces.size());
            DocumentBuilder builder = createDocumentBuilder();
            Document doc = builder.newDocument();
            doc.appendChild(doc.createComment(LICENSE_HEADER));
            Element privileges = (Element) doc.appendChild(doc.createElement(XML_PRIVILEGES));

            for (String prefix : namespaces.keySet()) {
                String uri = namespaces.get(prefix);
                privileges.setAttribute(ATTR_XMLNS + prefix, uri);
                uriToPrefix.put(uri, prefix);
            }

            for (PrivilegeDefinition def : definitions) {
                Element priv = (Element) privileges.appendChild(doc.createElement(XML_PRIVILEGE));
                priv.setAttribute(ATTR_NAME, getQualifiedName(def.getName(), uriToPrefix));
                priv.setAttribute(ATTR_ABSTRACT, Boolean.valueOf(def.isAbstract()).toString());

                for (Name aggrName : def.getDeclaredAggregateNames()) {
                    Element contains = (Element) priv.appendChild(doc.createElement(XML_CONTAINS));
                    contains.setAttribute(ATTR_NAME, getQualifiedName(aggrName, uriToPrefix));
                }
            }

            Transformer transformer = TRANSFORMER_FACTORY.newTransformer();
            transformer.setOutputProperty(OutputKeys.INDENT, "yes");
            transformer.setOutputProperty(OutputKeys.STANDALONE, "no");
            transformer.transform(new DOMSource(doc), result);

        } catch (Exception e) {
            IOException io = new IOException(e.getMessage());
            io.initCause(e);
            throw io;
        }
    }

    //--------------------------------------------------------------------------
    /**
     * Build a new <code>PrivilegeDefinition</code> from the given XML node.
     * @param n
     * @param namespaces
     * @return
     */
    private PrivilegeDefinition parseDefinition(Node n, Map<String, String> namespaces) {
        if (n.getNodeType() == Node.ELEMENT_NODE) {
            Element elem = (Element) n;

            updateNamespaceMapping(elem, namespaces);

            Name name = getName(elem.getAttribute(ATTR_NAME), namespaces);
            boolean isAbstract = Boolean.parseBoolean(elem.getAttribute(ATTR_ABSTRACT));

            Set<Name> aggrNames = new HashSet<Name>();
            NodeList nodeList = elem.getChildNodes();
            for (int i = 0; i < nodeList.getLength(); i++) {
                Node contains = nodeList.item(i);
                if (isElement(n) && XML_CONTAINS.equals(contains.getNodeName())) {
                    String aggrName = ((Element) contains).getAttribute(ATTR_NAME);
                    if (aggrName != null) {
                        aggrNames.add(getName(aggrName, namespaces));
                    }
                }
            }
            return new PrivilegeDefinitionImpl(name, isAbstract, aggrNames);
        }

        // could not parse into privilege definition
        return null;
    }

    /**
     * Create a new <code>DocumentBuilder</code>
     *
     * @return a new <code>DocumentBuilder</code>
     * @throws ParserConfigurationException
     */
    private static DocumentBuilder createDocumentBuilder() throws ParserConfigurationException {
        DocumentBuilder builder = DOCUMENT_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);
        builder.setErrorHandler(new DefaultHandler());
        return builder;
    }

    /**
     * Update the specified namespace mappings with the namespace declarations
     * defined by the given XML element.
     * 
     * @param elem
     * @param namespaces
     */
    private static void updateNamespaceMapping(Element elem, Map<String, String> namespaces) {
        NamedNodeMap attributes = elem.getAttributes();
        for (int i = 0; i < attributes.getLength(); i++) {
            Attr attr = (Attr) attributes.item(i);
            if (attr.getName().startsWith(ATTR_XMLNS)) {
                String prefix = attr.getName().substring(ATTR_XMLNS.length());
                String uri = attr.getValue();
                namespaces.put(prefix, uri);
            }
        }
    }

    /**
     * Returns <code>true</code> if the given XML node is an element.
     *
     * @param n
     * @return <code>true</code> if the given XML node is an element; <code>false</code> otherwise.
     */
    private static boolean isElement(Node n) {
        return n.getNodeType() == Node.ELEMENT_NODE;
    }

    private Name getName(String jcrName, Map<String,String> namespaces) {
       String prefix = Text.getNamespacePrefix(jcrName);
        String uri = (Name.NS_EMPTY_PREFIX.equals(prefix)) ? Name.NS_DEFAULT_URI : namespaces.get(prefix);
        return NAME_FACTORY.create(uri, Text.getLocalName(jcrName));
    }

    private String getQualifiedName(Name name, Map<String,String> uriToPrefix) {
        String uri = name.getNamespaceURI();
        String prefix = (Name.NS_DEFAULT_URI.equals(uri)) ? Name.NS_EMPTY_PREFIX : uriToPrefix.get(uri);
        return prefix + ":" + name.getLocalName();
    }
}
