/*
 * 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.test.api;

import org.xml.sax.helpers.DefaultHandler;
import org.xml.sax.Attributes;
import org.xml.sax.SAXException;
import org.apache.jackrabbit.test.AbstractJCRTest;
import org.junit.Assert;

import javax.jcr.nodetype.NodeType;
import javax.jcr.Session;
import javax.jcr.Item;
import javax.jcr.Node;
import javax.jcr.PathNotFoundException;
import javax.jcr.RepositoryException;
import javax.jcr.NodeIterator;
import javax.jcr.PropertyType;
import javax.jcr.PropertyIterator;
import javax.jcr.Property;
import javax.jcr.Value;
import javax.jcr.ValueFormatException;
import javax.jcr.RangeIterator;
import java.io.IOException;
import java.io.ByteArrayOutputStream;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Stack;
import java.util.Hashtable;
import java.util.Enumeration;
import java.util.Iterator;
import java.util.Map;

/**
 * ContentHandler implementation which checks if the system view export of
 * a node tree is compliant to the specification.
 */
class SysViewContentHandler extends DefaultHandler {

    // the session used
    protected Session session;
    // the path to start from
    protected String path;
    // the choices
    boolean skipBinary;
    boolean noRecurse;
    // the nodeElem in process
    NodeElemData currentNodeElem;
    // the parenNodetElem of the currentNodeElem
    NodeElemData parentNodeElem;
    // the propElem in process
    PropElemData currentPropElem;
    // the valueElem in process
    protected StringBuffer currentValue;
    // prefix mapping data
    protected Map<String, String> prefixes;
    // if the first node is yet treated
    protected boolean testRootDone;
    // The stack holding the opened nodeElems
    Stack<NodeElemData> nodeElemStack;

    // resolved QNames for well known node and property names
    private String jcrRoot;
    private String jcrPrimaryType;
    private String jcrMixinTypes;
    private String jcrUuid;
    private String svNode;
    private String svProperty;
    private String svName;
    private String svType;
    private String svValue;
    private String mixReferenceable;

    /**
     * Constructor
     * @param path Thepath to the  root node of the tree to be exported.
     * @param session The session used.
     * @param skipBinary Boolean if the binary properties are not exported.
     * @param noRecurse Boolean if only the root node of the tree should be exported.
     */
    public  SysViewContentHandler(String path, Session session,
                                  boolean skipBinary, boolean noRecurse) throws RepositoryException {
        this.session =  session;
        this.path = path;
        this.skipBinary = skipBinary;
        this.noRecurse = noRecurse;

        jcrRoot = session.getNamespacePrefix(AbstractJCRTest.NS_JCR_URI) + ":root";
        jcrPrimaryType = session.getNamespacePrefix(AbstractJCRTest.NS_JCR_URI) + ":primaryType";
        jcrMixinTypes = session.getNamespacePrefix(AbstractJCRTest.NS_JCR_URI) + ":mixinTypes";
        jcrUuid = session.getNamespacePrefix(AbstractJCRTest.NS_JCR_URI) + ":uuid";
        svNode = session.getNamespacePrefix(AbstractJCRTest.NS_SV_URI) + ":node";
        svProperty = session.getNamespacePrefix(AbstractJCRTest.NS_SV_URI) + ":property";
        svName = session.getNamespacePrefix(AbstractJCRTest.NS_SV_URI) + ":name";
        svType = session.getNamespacePrefix(AbstractJCRTest.NS_SV_URI) + ":type";
        svValue = session.getNamespacePrefix(AbstractJCRTest.NS_SV_URI) + ":value";
        mixReferenceable = session.getNamespacePrefix(AbstractJCRTest.NS_MIX_URI) + ":referenceable";
    }

    /**
     * Check if the given path is valid.
     * Init the neccessary data.
     * @throws SAXException
     */
    public void startDocument() throws SAXException {
        try {
            // Check the given path, init the treeState stack
            Item item = session.getItem(path);
            checkCondition("TestPath "+path+" is not a path to a node.", item.isNode());
            nodeElemStack = new Stack<NodeElemData>();
            currentNodeElem = new NodeElemData();
            currentNodeElem.name = item.getName();
            currentNodeElem.node = (Node) item;
            currentNodeElem.path = path;
            prefixes = new HashMap<String, String>();
            testRootDone = false;
        } catch (PathNotFoundException pe) {
            checkCondition("TestPath " + path + " is not a valid path."
                    + pe.toString(), false);
        } catch (RepositoryException re) {
            checkCondition("Could not determine test node: "
                    + re.toString(), false);
        }
    }

    // Collect all prefix mappings.
    public void startPrefixMapping(String prefix, String uri) {
        prefixes.put(prefix,uri);
    }


    public void startElement(String uri, String localName,
            String qName, Attributes attributes) throws SAXException {

        try {
            if (qName.equals(svNode)) {
                //attribute sv:name
                String nodeName = attributes.getValue(svName);
                if (noRecurse) {
                    if (!testRootDone) {
                        nodeElemStack.push(currentNodeElem);
                        testRootDone = true;
                        // correct root name?
                        if (currentNodeElem.node.getDepth()==0) {
                            checkCondition("Exported Root node has not the required " +
                                "element name 'jcr:root'.", nodeName.equals(jcrRoot));
                        }
                        // nothing else to do here
                    }
                    // rootNode yet done
                    else {
                        // only the testRootNode should be exported.
                        checkCondition("Child Node Element of testRoot Node " +
                            "element found although noRecursive is true.", !testRootDone);
                    }

                }
                else {
                    if (!testRootDone) {
                        nodeElemStack.push(currentNodeElem);
                        testRootDone = true;
                        // correct root name?
                        if (currentNodeElem.node.getDepth()==0) {
                            checkCondition("Exported Root node has not the required " +
                                "element name 'jcr:root'.", nodeName.equals(jcrRoot));
                        }
                        // nothing else to do here
                    }
                    // Collect the exported data in a NodeElemData object.
                    // every occurrence of an opened sv:node element
                    // creates such an object which will be put on the nodeElemStack.
                    // As this element will be popped from the stack when the node element
                    // is closed, the latest element on the stack represents the parent
                    // node element of the current node element.
                    else {
                        parentNodeElem = (NodeElemData) nodeElemStack.pop();

                        // get the node(s) with the found nodeName
                        NodeIterator nodeIter = parentNodeElem.node.getNodes(nodeName);

                        // create a new nodeElemData for this new node elem in process
                        currentNodeElem = new NodeElemData();
                        currentNodeElem.name = nodeName;

                        long size = getSize(nodeIter);
                        if (size >= 1) {
                            // Find the index of the child node,
                            // collect the childElems data of the parent
                            // ie for every name we count the number of child nodes with that name.
                            // Also get the child node with the correct index given by the
                            // position the node elem is found in the exported tree.
                            if (parentNodeElem.childNodeElemNames.containsKey(nodeName)) {
                                ChildNodeElem child =
                                    (ChildNodeElem) parentNodeElem.childNodeElemNames.get(nodeName);
                                child.number++;
                                currentNodeElem.index = child.number;
                                // get the node
                                String relPath = currentNodeElem.name + "[" + child.number + "]";
                                currentNodeElem.node = parentNodeElem.node.getNode(relPath);
                                currentNodeElem.path = currentNodeElem.node.getPath();
                                parentNodeElem.childNodeElemNames.put(nodeName,child);
                            }
                            else {
                                ChildNodeElem child = new ChildNodeElem();
                                child.name = nodeName;
                                child.number = 1;
                                currentNodeElem.index = child.number;
                                // get the node
                                String relPath = currentNodeElem.name + "[" + child.number + "]";
                                currentNodeElem.node = parentNodeElem.node.getNode(relPath);
                                currentNodeElem.path = currentNodeElem.node.getPath();
                                parentNodeElem.childNodeElemNames.put(nodeName,child);
                            }
                        }
                        else {
                            // no node found, this is an error.
                            checkCondition("No child node of node "+ parentNodeElem.path
                                    + " found with name:  "  + nodeName,false);
                        }
                        // push the parent data and the current node element data on the stack
                        nodeElemStack.push(parentNodeElem);
                        nodeElemStack.push(currentNodeElem);
                    }
                }
            }

            // Collect the property data found in a PropElemData object.
            // Collect the value(s) found in an ArrayList.
            else if (qName.equals(svProperty)) {
                currentPropElem = new PropElemData();
                currentPropElem.name = attributes.getValue(svName);
                currentPropElem.typeName = attributes.getValue(svType);
                currentPropElem.type = PropertyType.valueFromName(currentPropElem.typeName);
                currentPropElem.values = new ArrayList<String>();
            }

            else if (qName.equals(svValue)) {
                // init
                currentValue = new StringBuffer();
            }
            else {
                // invalid element name is used
                checkCondition("Invalid element name " + qName
                        + " in SysView export found",false);
            }
        } catch (PathNotFoundException pne) {
                checkCondition("Item not found during exportSysViewTest: "
                        + pne.toString(), false);
        } catch (RepositoryException re) {
            // what here?
        }
    }

    // Collect the value data
    public void characters(char[] ch, int start, int length) throws SAXException {
        if (currentValue != null)
            currentValue.append(ch, start, length);
    }


    public void endElement (String uri, String localName, String qName)
            throws SAXException {

        try {
            // The value is held in a ArrayList
            // ignoring if it is a  multivalue property or not.
            if (qName.equals(svValue)) {
                if (currentValue != null) {
                    String val = currentValue.toString();
                    if (currentPropElem.type != PropertyType.BINARY ||
                            (currentPropElem.type == PropertyType.BINARY && !skipBinary))
                        currentPropElem.values.add(val);
                }
            }

            else if (qName.equals(svProperty)) {
                // we know all props are exported before the first node
                currentNodeElem = (NodeElemData) nodeElemStack.pop();
                currentNodeElem.propElems.add(currentPropElem);
                nodeElemStack.push(currentNodeElem);
            }

            else if (qName.equals(svNode)) {
                currentNodeElem = (NodeElemData) nodeElemStack.peek();
                // now check all the stuff
                if (currentNodeElem.node == null) {
                    checkCondition("Tree structure of exported node does " +
                        "not match the tree structure of the repository.", false);
                }
                else {
                    // position of jcr:primaryType, jcr:mixinTypes and jcr:uuid if present
                    checkPropOrder(currentNodeElem);
                    // number of child nodes ok?
                    checkChildren(currentNodeElem, noRecurse);
                    // props and their values ok?
                    try {
                        checkAllProps(currentNodeElem, skipBinary);
                        // remove from the stack
                        nodeElemStack.pop();
                    }
                    catch (IOException ioe) {
                        checkCondition("Error in Base64 encoding " +
                                "of a binary property value: " + ioe.toString(), false);
                    }
                }
            }
            else {
                // invalid element name is used
                checkCondition("Invalid element name " + qName +
                        " in SysView export found",false);
            }
        } catch (PathNotFoundException pne) {
            checkCondition("Item not found during exportSysViewTest: " +
                    pne.toString(), false);
        }
        catch (RepositoryException re) {
            // what here?
        }
    }

    public void endDocument() throws SAXException {
        // check exported namespaces
        try {
            Map<String, String> sessionNamespaces = new HashMap<String, String>();
            String[] sessionPrefixes = session.getNamespacePrefixes();
            for (int i = 0; i < sessionPrefixes.length; i++) {
                sessionNamespaces.put(sessionPrefixes[i], session.getNamespaceURI(sessionPrefixes[i]));
            }

            // check prefixes against namespace mapping in session
            for (Iterator<String> it = prefixes.keySet().iterator(); it.hasNext(); ) {
                String prefix = it.next();
                if ("xml".equals(prefix)) {
                    Assert.fail("Prefix mapping for 'xml' must not be exported.");
                }

                String uri = prefixes.get(prefix);
                checkCondition("Exported uri " + uri + " is not a registered namespace.",
                        sessionNamespaces.containsValue(uri));
                checkCondition("Exported prefix " + prefix + " does not match " +
                        "current namespacce mapping in Session",
                        sessionNamespaces.containsKey(prefix));
            }
        } catch (RepositoryException re) {
            throw new SAXException(re);
        }
    }

    // helpers for test result forward
    private void checkCondition(String str, boolean bool) {
        Assert.assertTrue(str, bool);
    }

    public class ConditionException extends SAXException {
        public ConditionException(String message) {
            super(message);
        }
    }

    //--------------helper methods to check the data ---------------------------------
    /**
     * Checks the correct position of the jcr:primarType, jcr:mixinTypes
     * and jcr:uuid in the property elements of the given nodeElem.
     * @param nodeElem
     * @throws RepositoryException
     */
    private void checkPropOrder(NodeElemData nodeElem)
            throws RepositoryException, SAXException {

        boolean jcrPrimaryTypeFound = false;
        boolean jcrMixinTypesFound = true;
        boolean uuidFound = true;
        PropElemData propElem = nodeElem.propElems.get(0);
        jcrPrimaryTypeFound = (jcrPrimaryType.equals(propElem.name));
        checkCondition("Exported property jcr:primaryType of node " + nodeElem.path +
                " is not at the first position.", jcrPrimaryTypeFound);

        if (nodeElem.node.hasProperty(jcrMixinTypes)) {
            PropElemData propElem2 = nodeElem.propElems.get(1);
            jcrMixinTypesFound = (jcrMixinTypes.equals(propElem2.name));
            checkCondition("Exported property jcr:jcrMixinTypes of node " + nodeElem.path +
                " is not at the second position.", jcrMixinTypesFound);

            NodeType[] mixins = nodeElem.node.getMixinNodeTypes();
            for (int j=0; j<mixins.length; j++) {
                if (mixins[j].getName().equals(mixReferenceable)) {
                uuidFound = (jcrUuid.equals(nodeElem.propElems.get(2).name));
                checkCondition("Exported property jcr:uuid of node " + nodeElem.path +
                        " is not at the third position.", uuidFound);
                }
            }
        }
    }


    /**
     * Checks the values of all exported properties of a given node.
     *
     * @param nodeElem The nodeElem of the given node.
     * @param skipBinary Boolean if the binary properties should be exported.
     * @throws RepositoryException
     * @throws SAXException
     * @throws IOException
     */
    private void checkAllProps(NodeElemData nodeElem, boolean skipBinary)
            throws RepositoryException, SAXException, IOException {

        boolean allFound = false;
        boolean correctVal = false;
        Node node = nodeElem.node;
        List<PropElemData> propElems = nodeElem.propElems;

        // no props exported
        if (propElems.size() == 0) {
            // if node has properties they should be of Binary type and skipBinary should be true
            if (node.hasProperties()) {
                if (skipBinary) {
                    PropertyIterator iter = node.getProperties();
                    while (iter.hasNext()) {
                        Property prop = iter.nextProperty();
                        checkCondition("Property " + prop.getName() + " of node "
                            + node.getPath() + " is not exported.", prop.getType()== PropertyType.BINARY);
                    }
                }
                else {
                    checkCondition("One or more properties of node "
                        + node.getPath() + " are not exported.", false);
                }
            }
            else {
                // ok
            }
        }
        else {
            // compare the propElems with the properties of the given node
            for (int i = 0; i < propElems.size(); i++) {
                correctVal = false;
                PropElemData propElem = propElems.get(i);
                int propType = propElem.type;
                if (node.hasProperty(propElem.name)) {
                    Property prop = node.getProperty(propElem.name);
                    // compare the propTypes
                    correctVal = (propType == prop.getType());
                    checkCondition("Property type of property " + propElem.name
                                + " of node " + nodeElem.path + " is not exported correctly."
                            + "expected: "+prop.getType()+" received: "+propType, correctVal);

                    // property which should be exported
                    if (propType == PropertyType.BINARY && !skipBinary ||
                            propType != PropertyType.BINARY) {
                        try {
                            int size = propElem.values.size();

                            // multivalue property with empty value array
                            if (size == 0) {
                                if (prop.getDefinition().isMultiple()) {
                                    long length = prop.getValues().length;
                                    checkCondition("Value of property " + prop.getPath() +
                                             " is not exported.", length == 0);
                                }
                                else {
                                    checkCondition("Singler value property " + prop.getPath() +
                                             " with no value is exported.", false);
                                }
                            }

                            // single value property or multivalue property with one value
                            if (size == 1) {
                                String str = "";
                                if (prop.getDefinition().isMultiple()) {
                                    str = (prop.getValues()[0]).getString();
                                }
                                else {
                                    str = prop.getString();
                                }
                                String val = propElem.values.get(0);

                               if (prop.getType() == PropertyType.BINARY) {
                                    // decode value
                                    val = decodeBase64(val);
                                }
                                correctVal = (str.equals(val));
                                checkCondition("Property value of property " + propElem.name
                                    + " of node " + nodeElem.path + " is not exported correctly:" +
                                        " expected value: "+str+" found value: "+val, correctVal);
                            }

                            // multivalue property with several values
                            else {
                                Value[] vals = prop.getValues();
                                checkCondition("Number of exported values of property " +
                                        prop.getPath() + " does not match the number " +
                                        "its values", vals.length == size);
                                for (int j = 0; j < size; j++) {
                                    // we know that the order of the values
                                    // of a mulitval prop is preserved during export
                                    String val = propElem.values.get(j);

                                    if (prop.getType() == PropertyType.BINARY) {
                                        // decode value
                                        val = decodeBase64(val);
                                    }
                                    String str = vals[j].getString();
                                    correctVal = (val.equals(str));
                                    checkCondition("Property value of property " + propElem.name
                                            + " of node " + nodeElem.path +
                                            " is not exported correctly.", correctVal);
                                }
                            }
                        } catch (ValueFormatException vfe ) {
                                checkCondition("Error during retreiviing the value(s)" +
                                        " of property " + prop.getPath() + vfe.toString()
                                        + " .", false);
                        }
                    }
                    // skipBinary true and propType is Binary, should be skipped
                    else {
                        checkCondition("Value of binary property "+ prop.getPath()
                                + " exported although skipBinary flag is true.",
                                propElem.values.isEmpty());
                    }
                }
                // given node has no property with the name given by the prop element
                else {
                    checkCondition("Property element " + propElem.name
                            + " found but node " + nodeElem.node.getPath() +
                            " does not have a property with that name", false);
                }
            }
            // compare the sizes here
            long otherSize = getSize(node.getProperties());
            allFound = propElems.size() == otherSize;
            checkCondition("Not all properties of node " +
                    nodeElem.path + " are exported.", allFound);
        }
    }

    /**
     * Counts the number of child nodes exported and compare with the number
     * of child nodes of a given node.
     * @param nodeElem The node to check.
     * @param noRecurse Boolean if child nodes should be exported at all.
     * @throws RepositoryException
     */
    private void checkChildren(NodeElemData nodeElem, boolean noRecurse)
            throws RepositoryException {

        Hashtable<String, ChildNodeElem> childElemsFound = nodeElem.childNodeElemNames;
        boolean totalSumOk = false;
        boolean partialSumOk = true;
        if (noRecurse) {
            totalSumOk = (childElemsFound.size() == 0);
        }
        else {
            // all children found if number of node.getNodes(name) is the same as found
            // in childElemsFound and if sum(number of nodeGetNodes(names))
            // == number of node.getNodes()
            long childrenFound = 0;
            NodeIterator nodeIter = nodeElem.node.getNodes();

            long children = getSize(nodeIter);
            for (Enumeration<ChildNodeElem> e = childElemsFound.elements();  e.hasMoreElements();) {
                ChildNodeElem child = e.nextElement();
                String name = child.name;
                long number = child.number;

                NodeIterator iter = nodeElem.node.getNodes(name);
                long size = 0;

                size = getSize(iter);
                if (size != number) {
                    partialSumOk = false;
                    break;
                }
                else {
                    childrenFound += number;
                }
            }
            totalSumOk = (children == childrenFound);
            checkCondition("The number of child nodes of node" + nodeElem.path +
                    " which are exported does not match the number of its child nodes.",
                    totalSumOk && partialSumOk);
        }
    }

    // helper methods
    /**
     * Decodes Base64 encoded binary values.
     * @param str the string to decode
     * @return
     * @throws IOException
     */
    private String decodeBase64(String str) throws IOException {
        ByteArrayOutputStream bos = new ByteArrayOutputStream();
        Base64.decode(str, bos);
        String decoded = bos.toString("UTF-8");
        return decoded;
    }

    /**
     *
     * @param it
     * @return
     */
    private long getSize(RangeIterator it) {
        long size = it.getSize();
        if (size != -1) {
            return size;
        }
        size = 0;
        while (it.hasNext()) {
            it.next();
            size++;
        }
        return size;
    }

   //---------------- helper classes for collecting the xml data found ----------------

    /**
     * Node data class holding the collected data found during event processing.
     */
    private class NodeElemData {
        // Name of the node
        String name;
        // the number of the occurence of this name as child element name
        // this is then the same as the index of this node.
        long index;
        // the path of the node
        String path;
        // List of PropElemData
        List<PropElemData>  propElems = new ArrayList<PropElemData>();
        // the node itself
        Node node;
        // the current position of the child node in process among its same name siblings.
        // /the index of the child node in proces is therefore position+1)
        int position = 0;
        // the childNodeElems (stored are key: name and
        // value: number of the same name siblings)
        Hashtable<String, ChildNodeElem> childNodeElemNames = new Hashtable<String, ChildNodeElem>();
    }

    /**
     * Property data of the current property element.
     */
    private class PropElemData {
        String name;
        String typeName;
        int type;
        List<String> values;
    }

    /**
     * Child node data.
     */
    private class ChildNodeElem {
        String name;
        long number;
    }
}
