/*
 * XBeanCompare.java
 * Created on June 15, 2007, 11:43 AM
 */


package org.mitre.oval.repository.ws.moderator.application;

import java.util.HashMap;
import java.util.Map;
import org.apache.log4j.Logger;
import org.apache.xmlbeans.SchemaProperty;
import org.apache.xmlbeans.XmlCursor;
import org.apache.xmlbeans.XmlObject;
import org.apache.xmlbeans.impl.common.QNameHelper;
import org.apache.xmlbeans.impl.common.XmlWhitespace;

/**
 *
 * TODO: add in better difference reporting. It would be nice to report on what the differences are and where they were found.
 */
public class XBeanCompare {
    
    protected static Logger logger = Logger.getLogger(XBeanCompare.class.getPackage().getName());
    
    /**
     * Compares 2 XmlObjects and returns true if they are equal. Allows for an input list
     * of attributes to skip value checks and a list of attributes to skip existence checks.
     * The comparison of XmlObjects is done without regard to child element ordering.
     *
     * NOTE: I have attempted to make the code err on side of reporting elements to be different.
     *       2 elements should never be considered equal if they are not, but may be considered
     *       not equal when they actually are. This decision was made because this code was written
     *       with the primary goal of protecting the contents of a repository.
     *
     * This comparison is done as follows:
     * 1- compare the elements names. (see: XBeanCompare.compareNamesAndAttributes)
     * 2- compare the elements attributes. (see: XBeanCompare.compareNamesAndAttributes)
     * 3- check that both elements have the same number of children. (uses xpath "./child::*" then gets the length of the array of children)
     * 4- compare child elements
     *    - loop through all children of elm1
     *    - check that elm2 has a child element that matches the current elm1 child where the elm2 child has not already been used. (recursive)
     *    - each time a match is made store the index of the elm2 child to make sure it is not used again. (this protects use against two elements with the same attributes and values.)
     *
     * @param object1 the first XmlObject
     * @param object2 the sceond XmlObject
     * @param ignoreAttrValues A map of attribute names to not compare values on.
     * @param ignoreAttrExistence A map of attribute local names to skip when checking existenc of attributes
     * @return true if the two XmlObjects are equal.
     */
    public static boolean equals(XmlObject object1, XmlObject object2, Map ignoreAttrValues, Map ignoreAttrExistence) {
        boolean match = true;
        
        // Get cursors
        XmlCursor cur1 = object1.newCursor();
        XmlCursor cur2 = object2.newCursor();
        
        // start by making sure the element names and attribute are the same.
        if (!compareNamesAndAttributes(cur1, cur2, ignoreAttrValues, ignoreAttrExistence)) {
            match = false;
        } else {
            
            // now begin looking at child elements.
            boolean hasChildren1 = cur1.toFirstChild();
            boolean hasChildren2 = cur2.toFirstChild();
            
            if (hasChildren1 != hasChildren2) {
                
                // reset the cursors
                if(hasChildren1) {
                    cur1.toParent();
                }
                if(hasChildren2) {
                    cur2.toParent();
                }                
                
                // if one element has children and the other does not return false
                logger.debug("Topology differs: one element has " +
                        "children where the other does not (" +
                        QNameHelper.pretty(cur1.getName()) + ", " +
                        QNameHelper.pretty(cur2.getName()) + ").");
                
                match = false;
                
            } else if (!hasChildren1 && !hasChildren2) {
                
                // neither element has children so compare the element values making sure to collapse whitespace.
                if (!wsCollapseEqual(cur1.getTextValue(), cur2.getTextValue())) {
                    
                    logger.debug("Value '" + cur1.getTextValue() +
                            "' differs from value '" + cur2.getTextValue() + "'.");
                    
                    match = false;
                }
                
            } else {
                // both elements have children
                
                // get a count of all child elements for elm 1 and elm 2 - counts should be equal
                XmlObject[] obj1Children = object1.selectPath("./child::*");
                int obj1ChildCount = obj1Children.length;
                XmlObject[] obj2Children = object2.selectPath("./child::*");
                int obj2ChildCount = obj2Children.length;
                
                if(obj1ChildCount != obj2ChildCount) {
                    
                    // reset the cursors
                    if(hasChildren1) {
                        cur1.toParent();
                    }
                    if(hasChildren2) {
                        cur2.toParent();
                    }
                    
                    logger.debug("Topology differs: one element has " + obj1ChildCount +
                            " child element(s) where the other has " + obj2ChildCount + " child element(s) (" +
                            QNameHelper.pretty(cur1.getName()) + ", " +
                            QNameHelper.pretty(cur2.getName()) + ").");
                    
                    match = false;
                    
                } else {
                    
                    // track each elm 2 index as it is used.
                    Map matchedElmChildIndexes = new HashMap();
                    
                    boolean childMatch = false;
                    
                    // loop through all children of element 1
                    // stops when finished all children or
                    // when unable to find a matching child of elm2
                    do {
                        
                        // track the index of each child of elm 2
                        int elm2ChildIndex = 0;
                        
                        // look for a matching child elm in elm 2
                        do {
                            
                            // make sure the index has not been used.
                            if(!matchedElmChildIndexes.containsKey(new Integer(elm2ChildIndex))) {
                                // make recursive call
                                childMatch = equals(cur1.getObject(), cur2.getObject(), ignoreAttrValues, ignoreAttrExistence);
                                if(childMatch) {
                                    // store the index of the child
                                    matchedElmChildIndexes.put(new Integer(elm2ChildIndex), new Integer(elm2ChildIndex));
                                    
                                    // stop inner loop
                                    break;
                                }
                            }
                            
                            elm2ChildIndex++;
                        } while(cur2.toNextSibling());
                        
                        // reset the second cursor
                        cur2.toParent();
                        cur2.toFirstChild();
                        
                        // if no matching child was found break from the outter loop
                        if(!childMatch) {
                            match = false;
                            break;
                        }
                        
                    } while(cur1.toNextSibling());
                    
                    // if after processing all elm1 children the childMatch is true then rport true
                    if(childMatch) {
                        match = true;
                    } else {
                        match = false;
                    }
                    
                    // reset the cursors
                    cur1.toParent();
                    cur2.toParent();
                }
            }
        }
        // dispose the cursors
        cur1.dispose();
        cur2.dispose(); 
        
        return match;
    }
    
    /**
     * Return true if the elements pointed to by the input XmlCursors are equivalent.
     * Accepts as input two list of attributes to ignore durring comparison.
     * 
     * Attributes are compared as follows:
     * 1- loop through all attributes in elm1 and check elm2
     *    - if the attr exists in elm2 compare values.
     *    - if the attr does not exist use the schema type system to determine the default value for the elm2 attr if it has one.
     * 2- loop through all attributes in elm2 and check elm1
     *    - if the attr does not exist use the schema type system to determine the default value for the elm1 attr
     * 
     * 
     * @param cur1 The first XmlCursor.
     * @param cur2 The second XmlCursor.
     * @param ignoreAttrValues A map of attribute local names to not compare values on.
     * @param ignoreAttrExistence A map of attribute local names to skip when checking existence of attributes
     * @return 
     */
    private static boolean compareNamesAndAttributes(XmlCursor cur1, XmlCursor cur2, Map ignoreAttrValues, Map ignoreAttrExistence) {
        
        // make sure the qnames are equal.
        // if not return false
        if (!cur1.getName().equals(cur2.getName())) {
            logger.debug("Element names '" +
                    QNameHelper.pretty(cur1.getName()) + "' and '" +
                    QNameHelper.pretty(cur2.getName()) + "' do not match.");
            return false;
        }
        
        String elemName = QNameHelper.pretty(cur1.getName());
        
        // loop through all attributes on the first element if there are any;
        if (cur1.toFirstAttribute()) {
            do {
                
                // get the current attr name and value
                String localName1 = cur1.getName().getLocalPart();
                
                // check the list of attributes to skip comparing
                if(ignoreAttrExistence.containsKey(localName1)) {
                    
                    logger.debug("Ignoring attribute exitence check for: " + localName1);
                    
                } else {
                    
                    String text1 = cur1.getTextValue();
                    // fetch the value of the current attribute from the second element.
                    String text2 = cur2.getAttributeText(cur1.getName());
                    
                    // if the second elment does not have the attribute or the attribute does not have a default value return false .
                    if (text2 == null) {
                        
                        // look up attribute default value if it has one and use that for the comparison
                        SchemaProperty attrProperty = cur2.getObject().schemaType().getAttributeProperty(cur1.getName());
                        if(attrProperty.hasDefault() == SchemaProperty.CONSISTENTLY) {
                            
                            text2 = attrProperty.getDefaultText();
                            
                        } else {
                            
                            logger.debug("Attribute '" +
                                    QNameHelper.pretty(cur1.getName()) + "' " +
                                    " of element '" + elemName + "' not present.");
                            
                            return false;
                        }
                    }
                    
                    // check the list of attribute values to ignore.
                    if(ignoreAttrValues.containsKey(localName1)) {
                        
                        logger.debug("Ignoring attribute values for: " + localName1);
                        
                    } else {
                        // compare the attribute values ignoring whitespace differences.
                        // return false is not equal
                        if (!wsCollapseEqual(text1, text2)) {
                            logger.debug("Attribute values for '" +
                                    QNameHelper.pretty(cur1.getName()) + "' " +
                                    " of element '" + elemName + "' don't match.");
                            
                            return false;
                        }
                    }
                }
                
            } while (cur1.toNextAttribute());
            
            // reset the cursor after walking through the attributes.
            cur1.toParent();
        }
        
        // examine the attributes on the second element.
        // make sure that every attribute on the second element exists on the first element.
        // or if the attribute is not found on the first elm it is set to a default value - then must compare values.
        if (cur2.toFirstAttribute()) {
            do {
                
                // get the current attr name and value
                String localName2 = cur2.getName().getLocalPart();
                String text2 = cur2.getTextValue();
                
                // if the second elment does not have the attribute or the attribute does not have a default value return false.
                String text1 = cur1.getAttributeText(cur2.getName());
                if (text1 == null) {
                    
                    // look up attribute default value if it has one and use that for the comparison
                    SchemaProperty attrProperty = cur1.getObject().schemaType().getAttributeProperty(cur2.getName());
                    if(attrProperty.hasDefault() == SchemaProperty.CONSISTENTLY) {
                        
                        text1 = attrProperty.getDefaultText();
                        
                    } else {
                        
                        logger.debug("Attribute '" +
                                QNameHelper.pretty(cur1.getName()) + "' " +
                                " of element '" + elemName + "' not present.");
                        
                        return false;
                    }
                }
                
                // check the list of attribute values to ignore.
                if(ignoreAttrValues.containsKey(localName2)) {
                    
                    logger.debug("Ignoring attribute values for: " + localName2);
                    
                } else {
                    // compare the attribute values ignoring whitespace differences.
                    // return false is not equal
                    if (!wsCollapseEqual(text1, text2)) {
                        logger.debug("Attribute values for '" +
                                QNameHelper.pretty(cur2.getName()) + "' " +
                                " of element '" + elemName + "' don't match.");
                        
                        return false;
                    }
                }
                
            } while (cur2.toNextAttribute());
            
            // reset the cursor after walking through the attributes.
            cur2.toParent();
        }
        
        return true;
    }
    
    /**
     * Return true if the 2 strings are equal after xml collapsing whitespace.
     */
    private static boolean wsCollapseEqual(String s1, String s2) {
        logger.debug("Comparing: " + s1 + " to " + s2);
        String s1c = XmlWhitespace.collapse(s1);
        String s2c = XmlWhitespace.collapse(s2);
        return (s1c.equals(s2c));
    }
}

