package org.etsi.mts.ttcn.part9.xmldiff;

import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.Reader;
import java.io.StringReader;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.logging.FileHandler;
import java.util.logging.Level;
import java.util.logging.Logger;

import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import javax.xml.transform.Source;
import javax.xml.transform.stream.StreamSource;
import javax.xml.validation.Schema;
import javax.xml.validation.SchemaFactory;

import org.custommonkey.xmlunit.DetailedDiff;
import org.custommonkey.xmlunit.Diff;
import org.custommonkey.xmlunit.Difference;
import org.custommonkey.xmlunit.XMLUnit;
import org.w3c.dom.Document;
import org.xml.sax.InputSource;
import org.xml.sax.SAXException;

/** This class implements an algorithm to compare two XML files.
 * 
 * It is used as a utility in the ETSI STF on conformance testing for XSD processing (TTCN3 Part 9 specification).
 * @author pakulin
 *
 */
public class XmlDiff {
	public static LogUtil logger = null;
	static {
		logger = new LogUtil(Logger.getLogger("org.etsi.mts.ttcn.part9.xmldiff"));
		logger.logger.setLevel(Level.FINE);
		try {
			FileHandler fh = new FileHandler("XmlDiff.log", true);
			fh.setLevel(Level.FINE);
			logger.logger.addHandler(fh);
		} catch (SecurityException e) {
			e.printStackTrace();
		} catch (IOException e) {
			e.printStackTrace();
		}
	}

	static {
		XMLUnit.setIgnoreComments(true);
		XMLUnit.setIgnoreWhitespace(true);
		XMLUnit.setIgnoreDiffBetweenTextAndCDATA(true);
	}
	
	protected File referenceXmlFile;
	private List<File> xsdFiles;
	private Schema schemes = null;
	private DocumentBuilderFactory xmlParserFactory;
	private DiffErrorHandler errorHandler = new DiffErrorHandler();
	private Document controlDoc;
	private Document testDoc;

	/** Initialize the diff engine.
	 * 
	 * If {@code xsdFileNames} is <code>null</code> then the value of {@code xsdSearchPath}
	 * is ignored.
	 * 
	 * @param referenceXmlFile path to the reference XML file
	 * @param xsdFileNames optional list of XSD files relevant for the reference XML file.
	 * 	May be <code>null</code>.
	 * @param xsdSearchPath optional list of folder names and/or URIs where to look for XSD files. 
	 * 	May be <code>null</code>.
	 * 
	 */
	public XmlDiff(String referenceXmlFile, String[] xsdFileNames, String[] xsdSearchPath) {
		this(new File(referenceXmlFile), xsdFileNames, xsdSearchPath);
	}
	
	/** Initialize the diff engine.
	 * 
	 * If {@code xsdFileNames} is <code>null</code> then the value of {@code xsdSearchPath}
	 * is ignored.
	 * 
	 * @param file path to the reference XML file
	 * @param xsdFileNames optional list of XSD files relevant for the reference XML file.
	 * 	May be <code>null</code>.
	 * @param xsdSearchPath optional list of folder names and/or URIs where to look for XSD files. 
	 * 	May be <code>null</code>.
	 * 
	 */
	public XmlDiff(File file, String[] xsdFileNames, String[] xsdSearchPath) {
		logger.debug("Reference file: ", file, 
				", xsd file names: ", (xsdFileNames == null)? "null" : Arrays.toString(xsdFileNames),
				", xsd search path: ", (xsdSearchPath == null)? "null" : Arrays.toString(xsdSearchPath));		
		this.referenceXmlFile = file;
		if (!this.referenceXmlFile.exists()) {
			logger.severe("No such file: ", this.referenceXmlFile.getAbsolutePath());
			throw new IllegalArgumentException("No such file: " + this.referenceXmlFile.getAbsolutePath());
		}
		if (!this.referenceXmlFile.canRead()) {
			logger.severe("Can't read: ", this.referenceXmlFile.getAbsolutePath());
			throw new IllegalArgumentException("Can't read: " + this.referenceXmlFile.getAbsolutePath());
		}

		xmlParserFactory = DocumentBuilderFactory.newInstance();
		xmlParserFactory.setIgnoringComments(true);
		xmlParserFactory.setCoalescing(true);
		xmlParserFactory.setIgnoringElementContentWhitespace(true);
		
		xsdFiles = new ArrayList<File>();
		List<String> missing;
		
		missing = findXsdFiles(xsdFileNames, xsdSearchPath);
		if (missing != null) {
			logger.severe("Missing XSD files " + missing + " in search path " + Arrays.toString(xsdSearchPath));
			throw new IllegalArgumentException("Missing XSD files " + missing + " in search path " + Arrays.toString(xsdSearchPath));
		}
		
		if (xsdFiles.size() > 0) {
			addXmlSchemas();
		}
	}

	/** Compare an XML document against the reference one.
	 * @param input the text of the XML document
	 * @param errorMessage container to store details of differences. May be <code>null</code>
	 * @return <code>true</code> if the documents match and <code>false</false> otherwise.
	 * @throws XmlDiffError if an error occurs before or during diffing
	 */
	public boolean diff(String input, StringBuilder diffDetails) throws XmlDiffError {
		logger.debug("Looking for difference: reference file ", referenceXmlFile, ", input {", input, "}");
		InputStream stream;
		try {
			stream = new FileInputStream(referenceXmlFile);
		} catch (FileNotFoundException e) {
			logger.severe("File not found: ", referenceXmlFile.getAbsolutePath());
			throw new XmlDiffError(e); 
		}
		Reader rd = new InputStreamReader(stream, StandardCharsets.UTF_8);
		StringReader inputReader = new StringReader(input);
		boolean v = diff(rd, inputReader, diffDetails);
		if (!v) {
			logger.info("Compared reference file ", referenceXmlFile, " with input {", input, "}, found differences ", diffDetails);
		} else {
			logger.debug("No differences found between reference file ", referenceXmlFile, " and input {", input, "}");
		}
		return v;
	}

	/** Created for debugging purposes
	 * @param input
	 * @param diffDetails
	 * @return
	 * @throws XmlDiffError
	 */
	boolean diff(Reader input, StringBuilder diffDetails) throws XmlDiffError {
		InputStream stream;
		try {
			stream = new FileInputStream(referenceXmlFile);
		} catch (FileNotFoundException e) {
			throw new XmlDiffError(e); 
		}
		Reader rd = new InputStreamReader(stream, StandardCharsets.UTF_8);
		return diff(rd, input, diffDetails);
	}

	/***********************************************************************
	 * 
	 * Private methods
	 *  
	 ***********************************************************************/
	
	
	private boolean diff(Reader expected, Reader actual,
			StringBuilder diffDetails) throws XmlDiffError {
		
		Diff differ = null;
		try {
//			differ = new Diff(expected, actual);
			differ = createDiffer(expected, actual);
		} catch (SAXException e) {
			throw new XmlDiffError("Failed to parse XML", e);
		} catch (IOException e) {
			throw new XmlDiffError("Failed to read XML", e);
		}
		if (errorHandler.hasErrors()) {
			diffDetails.append(errorHandler.getErrorsText());
			return false;
		}
		
		boolean result = differ.identical() || differ.similar();
		
		if (!result) {
			DetailedDiff details = new DetailedDiff(differ);
			@SuppressWarnings("unchecked")
			List<Difference> diffs = details.getAllDifferences();
			if (diffs.isEmpty()) {
				result = true;
			} else {
				for (Difference diff : diffs) {
					diffDetails.append(diff.toString()).append('\n');
				}
			}
		}
		return result;
	}
	
	private void addXmlSchemas() {
		SchemaFactory schemaFactory = 
			    SchemaFactory.newInstance("http://www.w3.org/2001/XMLSchema");
		Source[] schemaSources = new Source[xsdFiles.size()];
		for (int i = 0; i < schemaSources.length; i++) {
			schemaSources[i] = new StreamSource(xsdFiles.get(i));
		}
		try {
			schemes  = schemaFactory.newSchema(schemaSources);
		} catch (SAXException e) {
			throw new IllegalArgumentException("Failed to parse a schema file", e);
		}
		if (schemes != null) {
			xmlParserFactory.setSchema(schemes);
			xmlParserFactory.setValidating(false);
			xmlParserFactory.setNamespaceAware(true);
		}
	}

	private List<String> findXsdFiles(String[] xsdFileNames, String[] xsdSearchPath) {
		if (xsdFileNames == null || xsdFileNames.length == 0) {
			return null;
		}
		List<String> missing = new ArrayList<String>();
		if (xsdSearchPath == null || xsdSearchPath.length == 0) {
			for (String name : xsdFileNames) {
				if (name == null) {
					logger.severe("XSD file name is nul!!!");
					continue;
				} else {
					logger.debug("Looking for XSD file ", name);
				}
				File guess = new File(name);
				if (guess.exists()) {
					xsdFiles.add(guess);
				} else {
					missing.add(name);
				}
			}
		} else {
			for (String fileName : xsdFileNames) {
				boolean found = false;
				for (String path: xsdSearchPath) {
					File guess = new File(path, fileName);
					if (guess.exists()) {
						xsdFiles.add(guess);
						found = true;
						break;
					}
				}
				if (!found) {
					missing.add(fileName);
				}
			}
		}
		return (missing.size() > 0) ? missing : null; 
	}

	private Document parseXml(Reader inReader, String kind) throws SAXException, IOException, XmlDiffError {
		DocumentBuilder parser;
		logger.debug2("Parse ", kind);
		try {
			parser = xmlParserFactory.newDocumentBuilder();
		} catch (ParserConfigurationException e) {
			logger.severe("Failed to setup a parser for ", kind, ": ", e.getMessage());
			throw new RuntimeException("Internal error: failed to create an XML parser", e);
		}
		parser.setErrorHandler(errorHandler);
		InputSource input = new InputSource(inReader);
		Document result = parser.parse(input);
		if (errorHandler.hasErrors()) {
			throw new XmlDiffError("Failed to parse " + kind + ": " + errorHandler.getErrorsText());
		}
		return result;
	}
	
	private Diff createDiffer(Reader control, Reader test) throws SAXException, IOException, XmlDiffError {
		controlDoc = parseXml(control, "sample XML file");
		testDoc = parseXml(test, "generated XML document");
		Diff result = new Diff(controlDoc, testDoc);
		result.overrideDifferenceListener(new DifferenceHandler(controlDoc, testDoc));
		return result;
	}
}
