/*
 * 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.camel.component.xmlsecurity.api;

import java.io.ByteArrayOutputStream;
import java.util.ArrayList;
import java.util.List;

import javax.xml.crypto.XMLStructure;
import javax.xml.crypto.dom.DOMStructure;
import javax.xml.crypto.dsig.Manifest;
import javax.xml.crypto.dsig.Reference;
import javax.xml.crypto.dsig.XMLObject;
import javax.xml.crypto.dsig.XMLSignature;
import javax.xml.crypto.dsig.XMLSignatureException;
import javax.xml.crypto.dsig.spec.XPathFilterParameterSpec;
import javax.xml.xpath.XPathConstants;
import javax.xml.xpath.XPathExpression;

import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;

import org.apache.camel.Exchange;
import org.apache.camel.Message;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * Maps the XML signature to a camel message. A output node is determined from the XML signature document via a node
 * search and then serialized and set to the output message body.
 * <p>
 * There are three output node search types supported: "Default", "ElementName", and "XPath". All these search types
 * support enveloped XML signature or enveloping XML signature.
 * <p>
 * <ul>
 * <li>The "ElementName" search uses the local name and namespace specified in the search value to determine the output
 * element from the XML signature document. With the input parameter 'RemoveSignatureElements", you can specify whether
 * the signature elements should be removed from the resulting output document. This flag shall be used for enveloped
 * XML signatures.
 * <li>The "XPath" search uses an XPath expression to evaluate the output node. In this case the output node can be of
 * type Element, TextNode, or Document. With the input parameter 'RemoveSignatureElements", you can specify whether the
 * signature elements should be removed from the resulting output document. This flag shall be used for enveloped XML
 * signatures.
 * <li>The "Default" search is explained in more detail below.
 * </ul>
 * <p>
 * Default Output Node Search:
 * <ul>
 * In the enveloped XML signature case, the XML document without the signature part is returned in the message body.
 * <p>
 * In the enveloping XML signature case, the message body is determined from a referenced Object element in the
 * following way:
 * <ul>
 * <li>Only same document references are taken into account (URI must start with '#').
 * <li>Also indirect same document references to an object via manifest are taken into account.
 * <li>The resulting number of object references must be 1.
 * <li>The referenced object must contain exactly 1 {@link DOMStructure}.
 * <li>The node of the DOMStructure is serialized to a byte array and added as body to the message.
 * </ul>
 * This does mean that the enveloping XML signature must have either the structure
 * 
 * <pre>
 *     {@code
 *     <Signature>
 *         <SignedInfo>
 *            <Reference URI="#object"/>       
 *            <!-- further references possible but they must not point to an Object or Manifest containing an object reference -->
 *            ...
 *         </SignedInfo>
 *     
 *         <Object Id="object">
 *              <!-- contains the DOM node which should be extracted to the message body -->
 *         <Object>
 *         <!-- further object elements possible which are not referenced-->
 *         ...
 *         (<KeyInfo>)?
 *     </Signature>
 *     }
 * </pre>
 * 
 * or the structure
 * 
 * <pre>
 *     {@code
 *     <Signature>
 *         <SignedInfo>
 *            <Reference URI="#manifest"/>       
 *            <!-- further references  are possible but they must not point to an Object or other manifest containing an object reference -->
 *            ...
 *         </SignedInfo>
 *     
 *         <Object >
 *            <Manifest Id="manifest">
 *               <Reference URI=#object/>
 *            </Manifest>
 *         </Objet>
 *         <Object Id="object">
 *             <!-- contains the DOM node which should be extracted to the message body -->
 *         </Object>
 *          <!-- further object elements possible which are not referenced -->
 *         ...
 *         (<KeyInfo>)?
 *     </Signature>
 *     }
 * </pre>
 * 
 * </ul>
 */
public class DefaultXmlSignature2Message implements XmlSignature2Message {

    /**
     * Search type 'Default' for determining the output node.
     * 
     */
    public static final String OUTPUT_NODE_SEARCH_TYPE_DEFAULT = "Default";

    /**
     * Search type 'ElementName' for determining the output element.
     * 
     */
    public static final String OUTPUT_NODE_SEARCH_TYPE_ELEMENT_NAME = "ElementName";

    /**
     * Search type 'XPath' for determining the output node. Search value must be of type
     * {@link XPathFilterParameterSpec}.
     * 
     */
    public static final String OUTPUT_NODE_SEARCH_TYPE_XPATH = "XPath";

    private static final Logger LOG = LoggerFactory.getLogger(DefaultXmlSignature2Message.class);

    @Override
    public void mapToMessage(Input input, Message output) throws Exception {

        Node node;
        boolean removeSignatureElements = false;
        if (OUTPUT_NODE_SEARCH_TYPE_DEFAULT.equals(input.getOutputNodeSearchType())) {
            LOG.debug("Searching for output node via default search");
            if (isEnveloping(input)) {
                node = getNodeForMessageBodyInEnvelopingCase(input);
            } else {
                // enveloped or detached XML signature  --> remove signature element
                node = input.getMessageBodyDocument().getDocumentElement();
                removeSignatureElements = true;
            }
        } else if (OUTPUT_NODE_SEARCH_TYPE_ELEMENT_NAME.equals(input.getOutputNodeSearchType())) {
            node = getOutputElementViaLocalNameAndNamespace(input);
        } else if (OUTPUT_NODE_SEARCH_TYPE_XPATH.equals(input.getOutputNodeSearchType())) {
            node = getOutputNodeViaXPath(input);
        } else {
            throw new XmlSignatureException(
                    String.format("Wrong configuration: The output node search type %s is not supported.",
                            input.getOutputNodeSearchType()));
        }

        LOG.debug("Output node with local name {} and namespace {} found", node.getLocalName(), node.getNamespaceURI());

        if (!removeSignatureElements) {
            removeSignatureElements = input.getRemoveSignatureElements() != null && input.getRemoveSignatureElements();
        }

        if (removeSignatureElements) {
            removeSignatureElements(node);
        }

        transformNodeToByteArrayAndSetToOutputMessage(input, output, node);
    }

    protected void transformNodeToByteArrayAndSetToOutputMessage(Input input, Message output, Node node)
            throws Exception {

        ByteArrayOutputStream os = new ByteArrayOutputStream();
        XmlSignatureHelper.transformToOutputStream(node, os, omitXmlDeclaration(output, input), input.getOutputXmlEncoding());
        output.setBody(os.toByteArray());
        if (input.getOutputXmlEncoding() != null) {
            output.setHeader(Exchange.CHARSET_NAME, input.getOutputXmlEncoding());
        }
    }

    protected Node getOutputNodeViaXPath(Input input) throws Exception {
        checkSearchValueNotNull(input);
        checkSearchValueOfType(XPathFilterParameterSpec.class, input);
        XPathFilterParameterSpec xpathFilter = (XPathFilterParameterSpec) input.getOutputNodeSearch();
        XPathExpression expr = XmlSignatureHelper.getXPathExpression(xpathFilter);
        NodeList nodes = (NodeList) expr.evaluate(input.getMessageBodyDocument(), XPathConstants.NODESET);
        if (nodes == null || nodes.getLength() == 0) {
            throw new XmlSignatureException(
                    String.format(
                            "Cannot extract root node for the output document from the XML signature document. No node found for XPATH %s as specified in the output node search.",
                            xpathFilter.getXPath()));
        }
        if (nodes.getLength() > 1) {
            throw new XmlSignatureException(
                    String.format(
                            "Cannot extract root node for the output document from the XML signature document. XPATH %s as specified in the output node search results into more than one child.",
                            xpathFilter.getXPath()));

        }
        Node result = nodes.item(0);
        if (Node.ELEMENT_NODE == result.getNodeType() || Node.TEXT_NODE == result.getNodeType()
                || Node.DOCUMENT_NODE == result.getNodeType()) {
            return result;
        }
        throw new XmlSignatureException(
                String.format("Cannot extract root node for the output document from the XML signature document. "
                              + "XPATH %s as specified in the output node search results into a node which has the wrong type.",
                        xpathFilter.getXPath()));
    }

    protected Node getOutputElementViaLocalNameAndNamespace(Input input) throws Exception {
        String search = getNonEmptyStringSearchValue(input);
        String namespace;
        String localName;
        if ('{' == search.charAt(0)) {
            // namespace
            int index = search.indexOf('}');
            if (index < 1) {
                throw new XmlSignatureException(
                        String.format(
                                "Wrong configuration: Value %s for the output node search %s has wrong format. "
                                      + "Value must have the form '{<namespace>}<element local name>' or '<element local name>' if no the element has no namespace.",
                                search, input.getOutputNodeSearchType()));
            }
            namespace = search.substring(1, index);
            if (search.length() < index + 1) {
                throw new XmlSignatureException(
                        String.format(
                                "Wrong configuration: Value %s for the output node search %s has wrong format. "
                                      + "Value must have the form '{<namespace>}<element local name>' or '<element local name>' if no the element has no namespace.",
                                search, input.getOutputNodeSearchType()));
            }
            localName = search.substring(index + 1);
        } else {
            namespace = null;
            localName = search;
        }
        NodeList nodeList = input.getMessageBodyDocument().getElementsByTagNameNS(namespace, localName);
        if (nodeList.getLength() == 0) {
            throw new XmlSignatureException(
                    String.format(
                            "Cannot extract root element for the output document from the XML signature document. Element with local name %s and namespace %s does not exist.",
                            namespace, localName));
        }
        if (nodeList.getLength() > 1) {
            throw new XmlSignatureException(
                    String.format(
                            "Cannot extract root element for the output document from the XML signature document. More than one element found with local name %s and namespace %s.",
                            namespace, localName));
        }
        return nodeList.item(0);
    }

    protected String getNonEmptyStringSearchValue(Input input) throws Exception {
        checkSearchValueNotNull(input);
        checkSearchValueOfType(String.class, input);
        String search = (String) input.getOutputNodeSearch();
        checkStringSarchValueNotEmpty(search, input.getOutputNodeSearchType());
        return search;
    }

    protected void checkSearchValueOfType(Class<?> cl, Input input) throws Exception {
        if (!cl.isAssignableFrom(input.getOutputNodeSearch().getClass())) {
            throw new XMLSignatureException(
                    String.format(
                            "Wrong configuration: Search value is of class %s, the output node search %s requires class %s.",
                            input
                                    .getOutputNodeSearch().getClass().getName(),
                            input.getOutputNodeSearchType(), cl.getName()));
        }

    }

    protected void checkStringSarchValueNotEmpty(String searchValue, String outputNodeSearchType) throws Exception {
        if (searchValue.isEmpty()) {
            throw new XMLSignatureException(
                    String.format("Wrong configuration: Value for output node search %s is empty.",
                            outputNodeSearchType));
        }
    }

    protected void checkSearchValueNotNull(Input input) throws Exception {
        LOG.debug("Searching for output element with search value '{}' and sarch type {}", input.getOutputNodeSearch(),
                input.getOutputNodeSearchType());
        if (input.getOutputNodeSearch() == null) {
            throw new XMLSignatureException(
                    String.format("Wrong configuration: Value is missing for output node search %s.",
                            input.getOutputNodeSearchType()));
        }
    }

    protected Node getNodeForMessageBodyInEnvelopingCase(Input input) throws Exception {
        Node node;
        List<Reference> relevantReferences = getReferencesForMessageMapping(input);

        List<XMLObject> relevantObjects = getObjectsForMessageMapping(input);

        DOMStructure domStruc = getDomStructureForMessageBody(relevantReferences, relevantObjects);
        node = domStruc.getNode();
        return node;
    }

    /**
     * Removes the Signature elements from the document.
     */
    protected void removeSignatureElements(Node node) {
        Document doc = XmlSignatureHelper.getDocument(node);
        NodeList nl = doc.getElementsByTagNameNS(XMLSignature.XMLNS, "Signature");
        List<Node> nodesToBeRemoved = new ArrayList<>(nl.getLength());
        for (int i = 0; i < nl.getLength(); i++) {
            // you cannot remove the nodes within this loop, because nl list would change
            nodesToBeRemoved.add(nl.item(i));
        }
        for (Node n : nodesToBeRemoved) {
            Node parent = n.getParentNode();
            if (parent != null) {
                parent.removeChild(n);
            }
        }
    }

    /**
     * Checks whether the XML document has as root element the signature element.
     * 
     * @param  input     XML signature input
     * @return           <code>true</code> if the root element of the xml signature document is the signature element;
     *                   otherwise <code>false</code>
     * @throws Exception
     */
    protected boolean isEnveloping(Input input) {
        Element el = input.getMessageBodyDocument().getDocumentElement();
        if ("Signature".equals(el.getLocalName()) && XMLSignature.XMLNS.equals(el.getNamespaceURI())) {
            return true;
        }
        return false;
    }

    protected Boolean omitXmlDeclaration(Message message, Input input) {
        Boolean omitXmlDeclaration = message.getHeader(XmlSignatureConstants.HEADER_OMIT_XML_DECLARATION, Boolean.class);
        if (omitXmlDeclaration == null) {
            omitXmlDeclaration = input.omitXmlDeclaration();
        }
        if (omitXmlDeclaration == null) {
            omitXmlDeclaration = Boolean.FALSE;
        }
        return omitXmlDeclaration;
    }

    /**
     * Returns the references whose referenced objects are taken into account for the message body. This message you can
     * use to filter the relevant references from the references provided by the input parameter.
     * 
     * 
     * @param  input     references and objects
     * @return           relevant references for the mapping to the camel message
     * @throws Exception if an error occurs
     */
    protected List<Reference> getReferencesForMessageMapping(Input input) throws Exception {
        return input.getReferences();
    }

    /**
     * Returns the objects which must be taken into account for the mapping to the camel message.
     * 
     * @param  input     references and objects
     * @return           relevant objects for the mapping to camel message
     * @throws Exception if an error occurs
     */
    protected List<XMLObject> getObjectsForMessageMapping(Input input) throws Exception {
        return input.getObjects();
    }

    /**
     * Returns the DOM structure which is transformed to a byte array and set to the camel message body.
     * 
     * @param  relevantReferences input from method {@link #getReferencesForMessageMapping(ReferencesAndObjects)}
     * @param  relevantObjects    input from method {@link #getObjectsForMessageMapping(ReferencesAndObjects)}
     * @return                    dom structure
     * @throws Exception          if an error occurs
     */
    protected DOMStructure getDomStructureForMessageBody(List<Reference> relevantReferences, List<XMLObject> relevantObjects)
            throws Exception {

        List<XMLObject> referencedObjects = getReferencedSameDocumentObjects(relevantReferences, relevantObjects);

        if (referencedObjects.isEmpty()) {
            throw new XmlSignatureException(
                    "Unsupported XML signature document: Content object not found in the enveloping XML signature.");
        }

        if (referencedObjects.size() > 1) {
            StringBuilder sb = new StringBuilder();
            for (int i = 0; i < referencedObjects.size(); i++) {
                XMLObject xmlOb = referencedObjects.get(i);
                sb.append(xmlOb.getId());
                if (i < referencedObjects.size() - 1) {
                    sb.append(", ");
                }
            }
            throw new XmlSignatureException(
                    String.format(
                            "Unsupported XML signature document: More than one content objects found. Object IDs: %s",
                            sb.toString()));
        }

        @SuppressWarnings("unchecked")
        List<XMLStructure> structures = referencedObjects.get(0).getContent();
        if (structures.isEmpty()) {
            throw new XmlSignatureException(
                    "Unsupported XML signature: XML signature is not enveloping; content not found in XML signature: structure list is empty.");
        }
        if (structures.size() > 1) {
            throw new XmlSignatureException(
                    "Unsupported XML signature: more than one structure elements in referenced content object.");
        }
        XMLStructure structure = structures.get(0);
        // only dom currently supported
        DOMStructure domStruc = (DOMStructure) structure;
        return domStruc;
    }

    protected List<XMLObject> getReferencedSameDocumentObjects(
            List<Reference> relevantReferences, List<XMLObject> relevantObjects) {
        List<XMLObject> referencedObjects = new ArrayList<>(1);

        for (Reference ref : relevantReferences) {
            String refUri = getSameDocumentReferenceUri(ref);
            if (refUri == null) {
                continue;
            }
            XMLObject referencedOb = getReferencedObject(relevantObjects, refUri);
            if (referencedOb != null) {
                referencedObjects.add(referencedOb);
                continue;
            }
            // content could also be indirectly referenced via manifest
            addManifestReferencedObjects(relevantObjects, referencedObjects, refUri);
        }
        return referencedObjects;
    }

    @SuppressWarnings("unchecked")
    protected void addManifestReferencedObjects(
            List<XMLObject> allObjects, List<XMLObject> referencedObjects, String manifestId) {
        Manifest manifest = getReferencedManifest(allObjects, manifestId);
        if (manifest == null) {
            return;
        }
        for (Reference manifestRef : (List<Reference>) manifest.getReferences()) {
            String manifestRefUri = getSameDocumentReferenceUri(manifestRef);
            if (manifestRefUri == null) {
                continue;
            }
            XMLObject manifestReferencedOb = getReferencedObject(allObjects, manifestRefUri);
            if (manifestReferencedOb != null) {
                referencedObjects.add(manifestReferencedOb);
            }
        }
    }

    protected String getSameDocumentReferenceUri(Reference ref) {
        String refUri = ref.getURI();
        if (refUri == null) {
            LOG.warn("Ignoring reference {} which has no URI", ref);
            return null;
        }
        if (!refUri.startsWith("#")) {
            LOG.warn("Ignoring non-same document reference {}", refUri);
            return null;
        }
        return refUri.substring(1);
    }

    protected Manifest getReferencedManifest(List<XMLObject> objects, String id) {
        for (XMLObject xo : objects) {
            @SuppressWarnings("unchecked")
            List<XMLStructure> content = xo.getContent();
            for (XMLStructure xs : content) {
                if (xs instanceof Manifest) {
                    Manifest man = (Manifest) xs;
                    if (id.equals(man.getId())) {
                        return man;
                    }
                }
            }
        }
        return null;
    }

    protected XMLObject getReferencedObject(List<XMLObject> objects, String id) {
        for (XMLObject ob : objects) {
            if (id.equals(ob.getId())) {
                return ob;
            }
        }
        return null;
    }
}
