/*
 * Copyright 2007 Edward Kuns
 *
 * Licensed 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.
 *
 * $Id: XMLDiff.java 0000 2007-01-11 00:00:00Z ekuns $
 */
package org.castor.xmlctf.xmldiff;

import java.io.PrintWriter;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.StringTokenizer;

import org.castor.xmlctf.xmldiff.xml.XMLFileReader;
import org.castor.xmlctf.xmldiff.xml.nodes.Attribute;
import org.castor.xmlctf.xmldiff.xml.nodes.Element;
import org.castor.xmlctf.xmldiff.xml.nodes.ParentNode;
import org.castor.xmlctf.xmldiff.xml.nodes.Root;
import org.castor.xmlctf.xmldiff.xml.nodes.XMLNode;

/**
 * A utility class used to compare two XMLNodes, or XML input files and report the differences
 * between them.
 *
 * @author <a href="mailto:edward.kuns@aspect.com">Edward Kuns</a>
 * @version $Revision: 0000 $ $Date: 2007-01-11 00:00:00 -0600 (Thu, 11 Jan 2007) $
 * @since Castor 1.1
 */
public class XMLDiff {

  /** The namespace of XML Schema. */
  private static final String XMLSCHEMA_INSTANCE = "http://www.w3.org/2001/XMLSchema-instance";

  /** Filename of the 1st XML item being compared. */
  private final String _file1;
  /** Filename of the 2nd XML item being compared. */
  private final String _file2;
  /** PrintWriter used for this comparison. */
  private final PrintWriter _pw = new PrintWriter(System.out, true);

  /**
   * A boolean to indicate whether or not child order should be strictly enforced. By default child
   * order is treated as "all", meaning that as long as a child exists, it doesn't matter which
   * order the children occur.
   */
  private boolean _strictChildOrder = false;
  /** True if we should print errors to the PrintWriter. */
  private boolean _print = true;
  /** True if we have not yet printed the diff header. */
  private boolean _header = true;

  /**
   * Constructs an XMLDiff object that is ready to compare the two provided XML files.
   *
   * @param file1 The first XML file for comparison.
   * @param file2 The second XML file for comparison.
   */
  public XMLDiff(final String file1, final String file2) {
    if (file1 == null) {
      String err = "The argument 'file1' may not be null.";
      throw new IllegalArgumentException(err);
    }

    if (file2 == null) {
      String err = "The argument 'file2' may not be null.";
      throw new IllegalArgumentException(err);
    }

    _file1 = file1;
    _file2 = file2;
  }

  /**
   * Compares the two XML documents located at the given URL locations. Returns 0, if no differences
   * are found, otherwise returns a positive number indicating the number of differences.
   * <p>
   * This is the only public method in this class.
   *
   * @return 0, if no differences are found, otherwise a positive number indicating the number of
   *         differences.
   * @throws java.io.IOException an this occurs while reading
   */
  public int compare() throws java.io.IOException {
    XMLFileReader reader1 = new XMLFileReader(_file1);
    XMLNode node1 = reader1.read();

    XMLFileReader reader2 = new XMLFileReader(_file2);
    XMLNode node2 = reader2.read();

    return compareNodes(node1, node2);
  }

  /**
   * Compares the given XMLNodes. Returns 0 if no differences are found, otherwise returns a
   * positive number indicating the number of differences.
   *
   * @param node1 the first XMLNode to compare
   * @param node2 the second XMLNode to compare
   * @return 0, if no differences are found, otherwise a positive number indicating the number of
   *         differences.
   */
  private int compareNodes(final XMLNode node1, final XMLNode node2) {
    // Compare node types
    if (!hasSameType(node1, node2)) {
      if (_print) {
        _pw.println("Types differ: <" + node1.getLocalName() + "> and <" + node2.getLocalName()
            + "> for" + node1.getNodeLocation());
      }
      return 1;
    }

    int diffCount = 0;

    String ns1 = node1.getNamespaceURI();
    String ns2 = node2.getNamespaceURI();
    if (!compareTextNullEqualsEmpty(ns1, ns2)) {
      if (_print) {
        _pw.println(
            "Namespaces differ: ('" + ns1 + "' != '" + ns2 + "') for " + node1.getNodeLocation());
      }

      ++diffCount;
    }

    // Compare local names (if both are null it's OK)
    String name1 = node1.getLocalName();
    String name2 = node2.getLocalName();

    if (name1 == null && name2 != null) {
      if (_print) {
        _pw.println("Names differ: null vs. <" + name2 + "> for " + node1.getNodeLocation());
      }
      ++diffCount;
      return diffCount;
    } else if (name2 == null && name1 != null) {
      if (_print) {
        _pw.println("Names differ: <" + name1 + "> vs null for " + node1.getNodeLocation());
      }
      ++diffCount;
      return diffCount;
    } else if (name1 != null && !name1.equals(name2)) {
      if (_print) {
        _pw.println(
            "Names differ: <" + name1 + "> != <" + name2 + "> for " + node1.getNodeLocation());
      }
      ++diffCount;
      return diffCount;
    }

    // Compare node content
    switch (node1.getNodeType()) {
      case XMLNode.ROOT:
        diffCount += compareElementsStrictOrder((Root) node1, (Root) node2);
        break;

      case XMLNode.ELEMENT:
        diffCount += compareElements((Element) node1, (Element) node2);
        break;

      case XMLNode.ATTRIBUTE:
        diffCount += compareStringValues(node1, node2);
        break;

      case XMLNode.TEXT:
        diffCount += compareStringValues(node1, node2);
        break;

      case XMLNode.PROCESSING_INSTRUCTION:
        // We don't care about comparing processing instructions
        break;

      default:
        System.out.println("Unexpected node type in XMLDiff: " + node1.getNodeType());
        break;
    }

    return diffCount;
  } // -- compare

  /**
   * Compares the String values of the provided XML Nodes.
   * 
   * @param node1 The first node to be compared
   * @param node2 The second node to be compared
   * @return 0 if the String values are the same, 1 otherwise
   */
  private int compareStringValues(final XMLNode node1, final XMLNode node2) {
    if (compareText(node1.getStringValue(), node2.getStringValue())) {
      return 0;
    }

    if (_print) {
      _pw.println();
      printLocationInfo(node1, node2);
      printText("- ", node1.getStringValue());
      _pw.println();
      printText("+ ", node2.getStringValue());
    }
    return 1;
  }

  /**
   * Checks the attributes of the given nodes to make sure they are identical but does not care
   * about the attribute order, as per XML 1.0.
   */
  private int compareAttributes(final Element node1, final Element node2) {
    int diffCount = 0;

    for (Iterator i = node1.getAttributeIterator(); i.hasNext();) {
      Attribute attr1 = (Attribute) i.next();

      // Does node2 have this attribute at all?
      String attValue2 = node2.getAttribute(attr1.getNamespaceURI(), attr1.getLocalName());
      if (attValue2 == null) {
        // Is this an attribute that is allowed to be missing sometimes?
        if (missingattributeIsIgnorable(attr1)) {
          continue;
        }

        // If not, complain
        printElementChangeBlock(node1, node2,
            "Attribute '" + attr1.getNodeLocation() + "' does not exist in the second document.");
        diffCount++;
        continue;
      }

      // If it does, does it have the same value?
      String attValue1 = attr1.getStringValue();
      if (!compareTextLikeQName(node1, node2, attValue1, attValue2)) {
        printElementChangeBlock(node1, node2,
            "Attribute '" + attr1.getNodeLocation() + "' values are different.");
        diffCount++;
      }
    }

    // Look for attributes on node 2 that are not on node 1
    for (Iterator i = node2.getAttributeIterator(); i.hasNext();) {
      Attribute attr2 = (Attribute) i.next();
      if (node1.getAttribute(attr2.getNamespaceURI(), attr2.getLocalName()) == null) {
        // Is this an attribute that is allowed to be missing sometimes?
        if (missingattributeIsIgnorable(attr2)) {
          continue;
        }

        // If not, complain
        printElementChangeBlock(node1, node2,
            "Attribute '" + attr2.getNodeLocation() + "' does not exist in the first document.");
        diffCount++;
      }
    }

    return diffCount;
  }

  private boolean missingattributeIsIgnorable(Attribute attr) {
    String name = attr.getLocalName();
    String ns = attr.getNamespaceURI();
    if (ns == null) {
      ns = "";
    }

    if (name.equals("noNamespaceSchemaLocation") && ns.equals(XMLSCHEMA_INSTANCE)) {
      return true;
    }
    if (name.equals("schemaLocation") && ns.equals(XMLSCHEMA_INSTANCE)) {
      return true;
    }
    return false;
  }

  /**
   * Compare the provided attribute text as if it were a QName.
   *
   * @param node1 Node containing attribute 1
   * @param node2 Node containing attribute 2
   * @param attValue1 String value of attribute 1
   * @param attValue2 String value of attribute 2
   * @return true if the attributes are equal directly, or equal when compared as QNames
   */
  private boolean compareTextLikeQName(final XMLNode node1, final XMLNode node2,
      final String attValue1, final String attValue2) {
    // If strings are equal, return equal
    if (compareText(attValue1, attValue2)) {
      return true;
    }

    // If neither attribute value has ":" then return false
    final int idx1 = attValue1.indexOf(':');
    final int idx2 = attValue2.indexOf(':');
    if (idx1 < 0 && idx2 < 0) {
      return false;
    }

    final String prefix1;
    final String prefix2;
    final String value1;
    final String value2;

    if (idx1 >= 0) {
      value1 = attValue1.substring(idx1 + 1);
      prefix1 = attValue1.substring(0, idx1);
    } else {
      value1 = attValue1;
      prefix1 = "";
    }

    if (idx2 >= 0) {
      value2 = attValue2.substring(idx2 + 1);
      prefix2 = attValue2.substring(0, idx2);
    } else {
      value2 = attValue2;
      prefix2 = "";
    }

    // Return true if text value is equal and namesspaces are equal
    return compareText(value1, value2) && compareTextNullEqualsEmpty(node1.getNamespaceURI(prefix1),
        node2.getNamespaceURI(prefix2));
  }

  /**
   * Compares the two XMLNodes, both of which must be of type XMLNode.ELEMENT or XMLNode.ROOT.
   *
   * @param node1 the primary XMLNode to comapare against
   * @param node2 the XMLNode to compare against node1
   * @return the number of differences found in this document tree
   */
  private int compareElements(final Element node1, final Element node2) {
    int diffCount = compareAttributes(node1, node2);

    if (_strictChildOrder) {
      diffCount += compareElementsStrictOrder(node1, node2);
    } else {
      diffCount += compareElementsLooseOrder(node1, node2);
    }
    return diffCount;
  }

  /**
   * Compares the two XMLNodes (not counting attributes) requiring strict child order, both of which
   * must be of type XMLNode.ELEMENT or XMLNode.ROOT.
   *
   * @param node1 the primary XMLNode to comapare against
   * @param node2 the XMLNode to compare against node1
   * @return the number of differences found in this document tree
   */
  private int compareElementsStrictOrder(final ParentNode node1, final ParentNode node2) {
    int diffCount = 0;

    Iterator i1 = node1.getChildIterator();
    Iterator i2 = node2.getChildIterator();

    // Skip all ignorable whitespace and compare with strict order
    if (i1.hasNext() && i2.hasNext()) {
      XMLNode child1 = (XMLNode) i1.next();
      XMLNode child2 = (XMLNode) i2.next();
      while (child1 != null && child2 != null) {
        if (nodeIsIgnorableText(child1)) {
          if (!i1.hasNext()) {
            break;
          }
          child1 = (XMLNode) i1.next();
          continue;
        }
        if (nodeIsIgnorableText(child2)) {
          if (!i2.hasNext()) {
            break;
          }
          child2 = (XMLNode) i2.next();
          continue;
        }

        diffCount += compareNodes(child1, child2);

        if (!i1.hasNext() || !i2.hasNext()) {
          break;
        }

        child1 = (XMLNode) i1.next();
        child2 = (XMLNode) i2.next();
      }
    }

    // If we have excess nodes for root1, complain about missing elements
    while (i1.hasNext()) {
      XMLNode child1 = (XMLNode) i1.next();
      if (!nodeIsIgnorableText(child1)) {
        if (_print) {
          printLocationInfo(child1, null);
          _pw.println("- ");
        }
        ++diffCount;
      }
    }

    // If we have excess nodes for root2, complain about extra elements
    while (i2.hasNext()) {
      XMLNode child2 = (XMLNode) i2.next();
      if (!nodeIsIgnorableText(child2)) {
        if (_print) {
          printLocationInfo(child2, null);
          _pw.println("- ");
        }
        ++diffCount;
      }
    }

    return diffCount;
  }

  /**
   * Compares the two XMLNodes, both of which must be of type XMLNode.ELEMENT or XMLNode.ROOT.
   *
   * @param node1 the primary XMLNode to comapare against
   * @param node2 the XMLNode to compare against node1
   * @return the number of differences found in this document tree
   */
  private int compareElementsLooseOrder(final Element node1, final Element node2) {
    int diffCount = 0;

    final List used = new LinkedList();

    for (Iterator i1 = node1.getChildIterator(); i1.hasNext();) {
      XMLNode child1 = (XMLNode) i1.next();
      // Ignore whitespace
      // If we find an exact match, continue with the next node in the list
      if (nodeIsIgnorableText(child1) || foundExactMatch(node2, child1, used)) {
        continue;
      }

      // Check for the best match and use it to count diffs & complain
      if (_print) {
        diffCount += closestMatchDifference(node2, child1, used);
      } else {
        diffCount++;
      }
    }

    // Complain about all children of node2 that are not used and not ignorable whitespace
    for (Iterator i2 = node2.getChildIterator(); i2.hasNext();) {
      XMLNode child2 = (XMLNode) i2.next();
      if (!nodeIsIgnorableText(child2) && !used.contains(child2)) {
        if (_print) {
          _pw.println("Extra child node: " + child2.getNodeLocation());
        }
        ++diffCount;
      }
    }

    return diffCount;
  }

  /**
   * Looks for an exact match for the provided target XMLNode. If found, returns true. Suppresses
   * complaints during search. If an exact match is found, the match is added to the list of "used"
   * items.
   *
   * @param parent The node whose children to search for an exact match
   * @param target The XMLNode we are trying to match
   * @param usedList The list of children of node2 that have already matched other objects
   * @return true if an exact match is found for the provided node.
   */
  private boolean foundExactMatch(final Element parent, XMLNode target, final List usedList) {
    // Suppress complaints when we are looking for an exact match.
    boolean previousPrint = _print;

    _print = false; // Suppress printing when we are "just looking"
    boolean found = false;
    for (Iterator i2 = parent.getChildIterator(); i2.hasNext();) {
      XMLNode child2 = (XMLNode) i2.next();
      if (!usedList.contains(child2) && compareNodes(target, child2) == 0) {
        usedList.add(child2);
        found = true;
        break;
      }
    }

    // Restore printing
    _print = previousPrint;
    return found;
  }

  /**
   * Looks for a close patch to the provided target XMLNode among the children of the provided
   * parent node. The difference between the closest match and the target is returned. If we cannot
   * even find a close match, then we declare the target missing and return one difference.
   * <p>
   * Note: This method is only called when printing is enabled.
   *
   * @param parent The node whose children to search for an exact match
   * @param target The XMLNode we are trying to match
   * @param usedList The list of children of node2 that have already matched other objects
   * @return the difference count
   */
  private int closestMatchDifference(final Element parent, final XMLNode target,
      final List usedList) {
    for (Iterator i2 = parent.getChildIterator(); i2.hasNext();) {
      XMLNode child2 = (XMLNode) i2.next();
      if (!usedList.contains(child2) && hasSameType(target, child2)
          && hasSameName(target, child2)) {
        usedList.add(child2);
        return compareNodes(target, child2);
      }
    }

    _pw.println(
        "Missing child node: " + target.getNodeLocation() + " for " + target.getNodeLocation());
    return 1;
  }

  /**
   * Returns true if the given node is a TEXT node that contains only ignorable whitespace.
   *
   * @return true if the given node is a TEXT node that contains only ignorable whitespace.
   */
  private boolean nodeIsIgnorableText(final XMLNode child) {
    return (child.getNodeType() == XMLNode.TEXT && compareText(child.getStringValue(), ""));
  }

  /**
   * Returns true if the two Strings are equal, ignoring whitespace differences that are ignorable.
   *
   * @return true if the two Strings are equal, ignoring whitespace differences that are ignorable.
   */
  private boolean compareText(final String s1, final String s2) {
    if (s1.equals(s2)) {
      return true;
    }

    // Strings are different; compare token by token to ignore whitespace differences
    StringTokenizer st1 = new StringTokenizer(s1);
    StringTokenizer st2 = new StringTokenizer(s2);

    while (st1.hasMoreTokens() && st2.hasMoreTokens()) {
      if (!st1.nextToken().equals(st2.nextToken())) {
        return false;
      }
    }

    // If the Strings have different numbers of tokens, fail
    if (st1.hasMoreTokens() || st2.hasMoreTokens()) {
      return false;
    }

    return true;
  }

  /**
   * Compares two strings. Considers null Strings to be the same as an empty String.
   *
   * @param one The first string to compare.
   * @param two The second string to compare.
   * @return true if the two strings are equals or are both "null or empty"
   */
  private boolean compareTextNullEqualsEmpty(String one, String two) {
    String text1 = (one == null) ? "" : one;
    String text2 = (two == null) ? "" : two;
    return text1.equals(text2);
  }

  private boolean hasSameName(final XMLNode node1, final XMLNode node2) {
    String name1 = node1.getLocalName();
    String name2 = node2.getLocalName();

    // ROOT may or may not have null name, so we must check for possible null values
    if (name1 == null) {
      return (name2 == null);
    }
    return name1.equals(name2);
  }

  private boolean hasSameType(final XMLNode node1, final XMLNode node2) {
    return (node1.getNodeType() == node2.getNodeType());
  }

  private void printLocationInfo(final XMLNode node1, final XMLNode node2) {
    if (_header) {
      _header = false;
      _pw.println("--- " + _file1);
      _pw.println("+++ " + _file2);
    }
    _pw.print("@@ -");
    _pw.print(node1.getNodeLocation());
    _pw.print(" +");
    _pw.print(node2.getNodeLocation());
    _pw.println(" @@");
  }

  private void printElementChangeBlock(final Element node1, final Element node2, final String msg) {
    if (_print) {
      _pw.print("- ");
      printElement(node1);
      _pw.print("+ ");
      printElement(node2);
      if (msg != null) {
        _pw.println(msg);
      }
    }
  }

  private void printElement(final Element node) {
    _pw.print('<' + node.getLocalName());

    for (Iterator i = node.getAttributeIterator(); i.hasNext();) {
      Attribute attr = (Attribute) i.next();
      _pw.print(' ');
      _pw.print(attr.getLocalName());
      _pw.print("=\"");
      _pw.print(attr.getStringValue());
      _pw.print("\"");
    }

    _pw.println('>');
  }

  /**
   * Prints the given text. Each line of the text is prefixed with the given prefix. If
   * <code>text</code> has multiple newlines, the prefix will be printed on each line.
   *
   * @param prefix A prefix to display on each line of output
   * @param text The text to display
   */
  private void printText(final String prefix, String text) {
    if (text == null) {
      _pw.println(prefix);
      return;
    }

    int idx = 0;
    while ((idx = text.indexOf('\n')) >= 0) {
      _pw.print(prefix);
      _pw.println(text.substring(0, idx));
      text = text.substring(idx + 1);
    }
    _pw.print(prefix);
    _pw.println(text);
  }

}
