/*
  * JBoss, Home of Professional Open Source
  * Copyright 2005, JBoss Inc., and individual contributors as indicated
  * by the @authors tag. See the copyright.txt in the distribution for a
  * full listing of individual contributors.
  *
  * This is free software; you can redistribute it and/or modify it
  * under the terms of the GNU Lesser General Public License as
  * published by the Free Software Foundation; either version 2.1 of
  * the License, or (at your option) any later version.
  *
  * This software is distributed in the hope that it will be useful,
  * but WITHOUT ANY WARRANTY; without even the implied warranty of
  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
  * Lesser General Public License for more details.
  *
  * You should have received a copy of the GNU Lesser General Public
  * License along with this software; if not, write to the Free
  * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
  * 02110-1301 USA, or see the FSF site: http://www.fsf.org.
  */
package org.jboss.test.xml;

import java.io.StringReader;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import javax.xml.namespace.QName;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
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.w3c.dom.Text;
import org.xml.sax.EntityResolver;
import org.xml.sax.ErrorHandler;
import org.xml.sax.InputSource;
import org.xml.sax.SAXException;
import org.xml.sax.SAXParseException;
import org.jboss.xb.binding.Constants;

/**
 * @author <a href="mailto:alex@jboss.org">Alexey Loubyansky</a>
 * @version <tt>$Revision: 45494 $</tt>
 */
public class XmlDiff
{
   public static final ErrorHandler ERROR_HANDLER = new DefErrorHandler();

   public static final byte PRINT_ELEMENT = 0;
   public static final byte PRINT_PARENT = 1;
   public static final byte PRINT_ALL = 2;

   private static final DocumentBuilderFactory FACTORY = DocumentBuilderFactory.newInstance();
   static
   {
      FACTORY.setNamespaceAware(true);
      FACTORY.setValidating(true);
   }

   private static final String INDENT = "  ";

   public static void main(String[] args) throws Exception
   {
      String xml1 =
         "<ns1:e xmlns:ns1='http://ns' attr1='attr1_val' ns1:attr2='attr2_val'>\n" +
         "  <ns1:child1>\n" +
         "     <ns2:child2 xmlns:ns2='http://ns2' child2_attr='child2_attr_val'>child2_val</ns2:child2>\n" +
         "  </ns1:child1>\n" +
         "  text\n" +
         "</ns1:e>";

      String xml2 =
         "<e xmlns='http://ns' attr1='attr1_val'" +
         " xmlns:ns='http://ns' ns:attr2='attr2_val'>text" +
         " <child1>" +
         "   <child2 xmlns='http://ns2' child2_attr='child2_attr_val'>child2_val</child2>" +
         " </child1>" +
         "</e>";

      System.out.println(new XmlDiff().diff(xml1, xml2));
   }

   public XmlDiff()
   {
      this(PRINT_ALL, true);
   }

   public XmlDiff(byte print, boolean reformat)
   {
      this.print = print;
      this.reformat = reformat;
   }

   private byte print = PRINT_ALL;
   private boolean reformat = true;

   public byte getPrint()
   {
      return print;
   }

   public void setPrint(byte print)
   {
      this.print = print;
   }

   public boolean isReformat()
   {
      return reformat;
   }

   public void setReformat(boolean reformat)
   {
      this.reformat = reformat;
   }

   /**
    * Compares two XML contents and returns a diff if they are different or null if they are equal.
    *
    * @param expected expected XML content
    * @param was      actual XML content
    * @return difference between XML contents or null if the contents are equal
    */
   public String diff(String expected, String was)
   {
      return diff(expected, was, ERROR_HANDLER, null);
   }

   public String diff(String expected, String was, ErrorHandler eh)
   {
      return diff(expected, was, eh, null);
   }

   public String diff(String expected, String was, EntityResolver er)
   {
      return diff(expected, was, ERROR_HANDLER, er);
   }

   public String diff(String expected, String was, ErrorHandler eh, EntityResolver er)
   {
      DocumentBuilder documentBuilder = null;
      try
      {
         documentBuilder = FACTORY.newDocumentBuilder();
      }
      catch(ParserConfigurationException e)
      {
         throw new IllegalStateException("Failed to create a document builder: " + e.getMessage());
      }

      if(eh != null)
      {
         documentBuilder.setErrorHandler(eh);
      }

      if(er != null)
      {
         documentBuilder.setEntityResolver(er);
      }

      Document expDoc = null;
      try
      {
         expDoc = documentBuilder.parse(new InputSource(new StringReader(expected)));
      }
      catch(Exception e)
      {
         throw new IllegalStateException("Failed to parse expected XML\n" + expected + ": " + e.getMessage());
      }

      Document wasDoc = null;
      try
      {
         wasDoc = documentBuilder.parse(new InputSource(new StringReader(was)));
      }
      catch(Exception e)
      {
         throw new IllegalStateException("Failed to parse XML\n" + was + ": " + e.getMessage());
      }

      Element expElement = expDoc.getDocumentElement();
      Element wasElement = wasDoc.getDocumentElement();
      return assertEquals(expElement, wasElement, expElement, wasElement);
   }

   private String assertEquals(Element exp, Element was, Element printAsExp, Element printAsWas)
   {
      QName expName = new QName(exp.getNamespaceURI(), exp.getLocalName());
      QName wasName = new QName(was.getNamespaceURI(), was.getLocalName());

      if(!expName.equals(wasName))
      {
         return fail("Expected name " + expName + " but was " + wasName, exp, was);
      }

      NamedNodeMap expAttrs = exp.getAttributes();
      NamedNodeMap wasAttrs = was.getAttributes();

      if(expAttrs == null && wasAttrs != null && hasNonIgnorableNs(wasAttrs))
      {
         return fail("Element " + expName + " doesn't have attributes", printAsExp, printAsWas);
      }
      else if(wasAttrs == null && expAttrs != null && hasNonIgnorableNs(expAttrs))
      {
         return fail("Element " + expName + " has attributes", printAsExp, printAsWas);
      }
      else if(expAttrs != null && wasAttrs != null)
      {
         String msg = assertAttrs(expAttrs, wasAttrs, printAsExp);
         if(msg != null)
         {
            return fail(msg, printAsExp, printAsWas);
         }
      }

      NodeList expChildren = exp.getChildNodes();
      NodeList wasChildren = was.getChildNodes();

      NodeList expTexts = getTextNodes(expChildren);
      NodeList wasTexts = getTextNodes(wasChildren);
      if(expTexts.getLength() > 0 && wasTexts.getLength() == 0)
      {
         return fail("Element " + expName + " has text content", printAsExp, printAsWas);
      }
      else if(expTexts.getLength() == 0 && wasTexts.getLength() > 0)
      {
         return fail("Element " + expName + " doesn't have text content", printAsExp, printAsWas);
      }
      // todo: should text content be concatenated before comparison?
      else if(expTexts.getLength() != wasTexts.getLength())
      {
         return fail(
            "Element " + expName + " has " + expTexts.getLength() + " text nodes (was " + wasTexts.getLength() + ")",
            printAsExp,
            printAsWas
         );
      }
      else if(expTexts.getLength() > 0 && wasTexts.getLength() > 0)
      {
         for(int i = 0; i < expTexts.getLength(); ++i)
         {
            Text text = (Text)expTexts.item(i);
            if(!containsText(text.getNodeValue(), wasTexts, i))
            {
               return fail("Element " + expName + " has text '" + text.getNodeValue() + "'", printAsExp, printAsWas);
            }
         }
      }

      NodeList expElems = sublist(expChildren, Node.ELEMENT_NODE);
      NodeList wasElems = sublist(wasChildren, Node.ELEMENT_NODE);
      if(expElems.getLength() > 0 && wasElems.getLength() == 0)
      {
         return fail("Element " + expName + " has child elements", printAsExp, printAsWas);
      }
      else if(expElems.getLength() == 0 && wasElems.getLength() > 0)
      {
         return fail("Element " + expName + " doesn't have child elements", printAsExp, printAsWas);
      }
      else if(expElems.getLength() != wasElems.getLength())
      {
         return fail("Element " +
            expName +
            " has " +
            expElems.getLength() +
            " child elements (was " +
            wasElems.getLength() +
            ")",
            printAsExp,
            printAsWas
         );
      }
      else if(expElems.getLength() > 0 && wasElems.getLength() > 0)
      {
         if(print == PRINT_PARENT)
         {
            printAsExp = exp;
            printAsWas = was;
         }

         for(int i = 0; i < expElems.getLength(); ++i)
         {
            Element expChild = (Element)expElems.item(i);
            Element wasChild = getElement(expChild.getNamespaceURI(), expChild.getLocalName(), wasElems, i);
            if(wasChild == null)
            {
               return fail("Element " +
                  expName +
                  " has child element " +
                  new QName(expChild.getNamespaceURI(), expChild.getLocalName()),
                  printAsExp,
                  printAsWas
               );
            }

            if(print == PRINT_ELEMENT)
            {
               printAsExp = expChild;
               printAsWas = wasChild;
            }

            String diff = assertEquals(expChild, wasChild, printAsExp, printAsWas);
            if(diff != null)
            {
               return diff;
            }
         }
      }
      return null;
   }

   private static Element getElement(String ns, String local, NodeList elements, int suggestedIndex)
   {
      if(suggestedIndex >= 0 && suggestedIndex < elements.getLength())
      {
         Element element = (Element)elements.item(suggestedIndex);
         if((ns == null && element.getNamespaceURI() == null ||
            ns != null && ns.equals(element.getNamespaceURI())
            ) &&
            local.equals(element.getLocalName()))
         {
            return element;
         }
      }

      for(int i = 0; i < elements.getLength(); ++i)
      {
         Element element = (Element)elements.item(i);
         if((ns == null && element.getNamespaceURI() == null ||
            ns != null && ns.equals(element.getNamespaceURI())
            ) &&
            local.equals(element.getLocalName()))
         {
            return element;
         }
      }
      return null;
   }

   private static boolean containsText(String text, NodeList textNodes, int suggestedIndex)
   {
      text = text.trim();
      if(suggestedIndex >= 0)
      {
         Text textNode = (Text)textNodes.item(suggestedIndex);
         String wasText = textNode.getNodeValue().trim();
         if(text.equals(wasText))
         {
            return true;
         }
      }

      for(int i = 0; i < textNodes.getLength(); ++i)
      {
         Text textNode = (Text)textNodes.item(i);
         String wasText = textNode.getNodeValue().trim();
         if(text.equals(wasText))
         {
            return true;
         }
      }
      return false;
   }

   private static NodeList getTextNodes(NodeList list)
   {
      MutableNodeList result = new MutableNodeList();
      for(int i = 0; i < list.getLength(); ++i)
      {
         Node node = list.item(i);
         if(node.getNodeType() == Node.TEXT_NODE)
         {
            String text = node.getNodeValue();
            if(text.trim().length() > 0)
            {
               result.add(node);
            }
         }
      }
      return result;
   }

   private static NodeList sublist(NodeList list, short nodeType)
   {
      MutableNodeList result = new MutableNodeList();
      for(int i = 0; i < list.getLength(); ++i)
      {
         Node node = list.item(i);
         if(node.getNodeType() == nodeType)
         {
            result.add(node);
         }
      }
      return result;
   }

   private static String assertAttrs(NamedNodeMap attrsExp,
                                     NamedNodeMap attrsWas,
                                     Element printAsExp)
   {
      String result = assertSubset(attrsExp, attrsWas, printAsExp, true);
      if(result == null)
      {
         result = assertSubset(attrsWas, attrsExp, printAsExp, false);
      }
      return result;
   }

   private static String assertSubset(NamedNodeMap attrsSubset,
                                      NamedNodeMap attrsSet,
                                      Element printAsExp,
                                      boolean checkHave)
   {
      String msg = checkHave ? " has attribute " : " doesn't have attribute ";
      QName expName = new QName(printAsExp.getNamespaceURI(), printAsExp.getLocalName());
      for(int i = 0; i < attrsSubset.getLength(); ++i)
      {
         Attr attr = (Attr)attrsSubset.item(i);
         String attrNs = attr.getNamespaceURI();
         String localName = attr.getLocalName();
         if(xsiNs(attrNs) && "type".equals(localName))
         {
            Attr wasAttr = (Attr)attrsSet.getNamedItemNS(attrNs, localName);
            if(wasAttr == null)
            {
               return "Element " + expName + msg + new QName(attrNs, localName);
            }

            String typeName = attr.getValue();
            int colon = typeName.indexOf(':');
            if(colon != -1)
            {
               typeName = typeName.substring(colon);
            }

            if(!wasAttr.getValue().endsWith(typeName))
            {
               return "Element " + expName +
                  (checkHave ? " has xsi:type " : " doesn't have xsi:type ") +
                  attr.getValue();
            }

            //todo compare namespaces for xsi:types
         }
         else if(nonIgnorableNs(attrNs) || xsiNs(attrNs) && localName.equals("nil"))
         {
            Attr wasAttr = (Attr)attrsSet.getNamedItemNS(attrNs, localName);
            if(wasAttr == null)
            {
               return "Element " + expName + msg + new QName(attrNs, localName);
            }

            if(!attr.getValue().equals(wasAttr.getValue()))
            {
               return "Attribute " +
                  new QName(attrNs, localName) +
                  " in element " +
                  expName +
                  " has value " + attr.getValue();
            }
         }
      }      
      return null;
   }
   
   private static boolean hasNonIgnorableNs(NamedNodeMap nodeMap)
   {
      for(int i = 0; i < nodeMap.getLength(); ++i)
      {
         Node node = nodeMap.item(i);
         if(nonIgnorableNs(node.getNamespaceURI()))
         {
            return true;
         }
      }
      return false;
   }

   private static boolean nonIgnorableNs(String ns)
   {
      return ns == null ||
         !ns.equals(Constants.NS_XML_SCHEMA)
         && !ns.equals(Constants.NS_XML_SCHEMA_INSTANCE)
         && !ns.equals(Constants.NS_XML_XMLNS);
   }

   private static boolean xsiNs(String ns)
   {
      return Constants.NS_XML_SCHEMA_INSTANCE.equals(ns);
   }

   private String fail(String msg, Element exp, Element was)
   {
      return msg + ". Expected\n" + toString(exp) + "\nbut was\n" + toString(was);
   }

   private String toString(Element e)
   {
      return append(e, new StringBuffer(), 0).toString();
   }

   private StringBuffer append(Element e, StringBuffer buf, int depth)
   {
      if(reformat && depth > 0)
      {
         buf.append('\n');
         for(int i = 0; i < depth; ++i)
         {
            buf.append(INDENT);
         }
      }

      buf.append('<');
      if(e.getPrefix() != null && e.getPrefix().length() > 0)
      {
         buf.append(e.getPrefix()).append(':');
      }
      buf.append(e.getLocalName());

      NamedNodeMap attrs = e.getAttributes();
      if(attrs != null && attrs.getLength() > 0)
      {
         for(int i = 0; i < attrs.getLength(); ++i)
         {
            Attr attr = (Attr)attrs.item(i);
            buf.append(' ')
               .append(attr.getName())
               .append('=')
               .append('\'')
               .append(attr.getValue())
               .append('\'');
         }
      }

      buf.append('>');

      NodeList childNodes = e.getChildNodes();
      boolean childElements = false;
      for(int i = 0; i < childNodes.getLength(); ++i)
      {
         Node child = childNodes.item(i);
         switch(child.getNodeType())
         {
            case Node.TEXT_NODE:
               String chars = child.getNodeValue();
               if(chars.trim().length() > 0)
               {
                  buf.append(chars);
               }
               break;
            case Node.ELEMENT_NODE:
               append((Element)child, buf, depth + 1);
               childElements = true;
               break;
            default:
               throw new IllegalStateException("Node type is not supported: " + child.getNodeType());
         }
      }

      if(reformat && childElements)
      {
         buf.append('\n');
         for(int i = 0; i < depth; ++i)
         {
            buf.append(INDENT);
         }
      }

      buf.append("</");
      if(e.getPrefix() != null && e.getPrefix().length() > 0)
      {
         buf.append(e.getPrefix()).append(':');
      }
      buf.append(e.getLocalName())
         .append('>');

      return buf;
   }

   // Inner

   private static final class MutableNodeList
      implements NodeList
   {
      private List<Node> list = Collections.emptyList();

      public void add(Node node)
      {
         switch(list.size())
         {
            case 0:
               list = Collections.singletonList(node);
               break;
            case 1:
               list = new ArrayList<Node>(list);
            default:
               list.add(node);
         }
      }

      public int getLength()
      {
         return list.size();
      }

      public Node item(int index)
      {
         return (Node)list.get(index);
      }
   }

   public static final class DefErrorHandler
      implements ErrorHandler
   {
      public static final byte IGNORE = 0;
      public static final byte LOG = 1;
      public static final byte FAIL = 3;

      private byte warnEvent = IGNORE;
      private byte errorEvent = IGNORE;
      private byte fatalEvent = FAIL;

      public void error(SAXParseException e) throws SAXException
      {
         handleEvent(warnEvent, e);
      }

      public void fatalError(SAXParseException e) throws SAXException
      {
         handleEvent(errorEvent, e);
      }

      public void warning(SAXParseException e) throws SAXException
      {
         handleEvent(fatalEvent, e);
      }

      private void handleEvent(byte event, SAXParseException e)
         throws SAXException
      {
         switch(event)
         {
            case IGNORE:
               break;
            case LOG:
               System.out.println(formatMessage(e));
               break;
            case FAIL:
               String msg = formatMessage(e);
               throw new SAXException(msg);
         }
      }
   }

   private static String formatMessage(SAXParseException exception)
   {
      StringBuffer buffer = new StringBuffer(50);
      buffer.append(exception.getMessage()).append(" @ ");
      String location = exception.getPublicId();
      if(location != null)
      {
         buffer.append(location);
      }
      else
      {
         location = exception.getSystemId();
         if(location != null)
         {
            buffer.append(location);
         }
         else
         {
            buffer.append("*unknown*");
         }
      }
      buffer.append('[');
      buffer.append(exception.getLineNumber()).append(',');
      buffer.append(exception.getColumnNumber()).append(']');
      return buffer.toString();
   }
}
