// $Id: ChangeTrackingVerifier.java 6763 2010-07-21 08:29:54Z tristanm $
// Copyright (c) 2010 DeltaXML Ltd. All rights reserved
/*  This program is free software: you can redistribute it and/or modify
    it under the terms of the GNU Lesser General Public License version 3 only,
    as published by the Free Software Foundation.

    This program 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 version 3 for more details
    (a copy is included in the LICENSE-LGPL.txt file that accompanied this code).

    You should have received a copy of the GNU Lesser General Public License
    along with this program.  If not, see <http://www.gnu.org/licenses/> */
package com.deltaxml.odf.ct;

import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.ArrayList;
import java.util.Enumeration;
import java.util.List;
import java.util.StringTokenizer;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
import java.util.zip.ZipOutputStream;

import javax.xml.parsers.SAXParserFactory;
import javax.xml.transform.Transformer;
import javax.xml.transform.TransformerException;
import javax.xml.transform.TransformerFactory;
import javax.xml.transform.sax.SAXSource;
import javax.xml.transform.stream.StreamResult;
import javax.xml.transform.stream.StreamSource;

import net.sf.saxon.s9api.Processor;
import net.sf.saxon.s9api.QName;
import net.sf.saxon.s9api.SaxonApiException;
import net.sf.saxon.s9api.XPathCompiler;
import net.sf.saxon.s9api.XPathExecutable;
import net.sf.saxon.s9api.XPathSelector;
import net.sf.saxon.s9api.XdmAtomicValue;

import org.iso_relax.verifier.Schema;
import org.iso_relax.verifier.Verifier;
import org.iso_relax.verifier.VerifierConfigurationException;
import org.iso_relax.verifier.VerifierFactory;
import org.testng.Assert;
import org.testng.annotations.Parameters;
import org.testng.annotations.Test;
import org.xml.sax.ErrorHandler;
import org.xml.sax.InputSource;
import org.xml.sax.SAXException;
import org.xml.sax.SAXParseException;

import com.sun.msv.verifier.jarv.TheFactoryImpl;

public class ChangeTrackingVerifier {
  
  SAXParserFactory spf;
  TransformerFactory tf;
  
  ZipFileToSingleFile fileFormatConverter;
  Verifier verifier= null;
  
  private boolean verbose;
  
  boolean relaxNGValidation= Boolean.parseBoolean(System.getProperty("rng", "true"));
  boolean schematronValidation= Boolean.parseBoolean(System.getProperty("schematron", "true"));
  
  private final String SCHEMA_FILE= "schema/odt-delta.rng";
  
  public ChangeTrackingVerifier() throws Exception {
    spf= SAXParserFactory.newInstance();
    spf.setNamespaceAware(true);
    tf= TransformerFactory.newInstance();
    fileFormatConverter= new ZipFileToSingleFile();
    if (relaxNGValidation) {
      VerifierFactory verfiyFactory= new TheFactoryImpl();
      Schema openDocumentSchema;
      InputStream schemaStream= this.getClass().getClassLoader().getResourceAsStream(SCHEMA_FILE);
      InputSource schemaSrc= new InputSource(schemaStream);
      schemaSrc.setSystemId(this.getClass().getClassLoader().getResource(SCHEMA_FILE).toString());
      try {
        openDocumentSchema= verfiyFactory.compileSchema(schemaSrc);
        verifier= openDocumentSchema.newVerifier();
      } catch (IOException e) {
        System.out.println("IOException getting Schema file '" + SCHEMA_FILE + "'");
        e.printStackTrace();
        throw e;
      } catch (SAXException e) {
        System.out.println("Cannot compile schema '" + SCHEMA_FILE + "'");
        e.printStackTrace();
        throw e;
      } catch (VerifierConfigurationException e) {
        System.out.println("Cannot configure Verifier");
        e.printStackTrace();
        throw e;
      }
    }
    
  }
  
  private void copyZipEntries(ZipFile inputZip, ZipOutputStream resultZip, String[] exclusions) throws Exception {
    List<String> excludeList= new ArrayList<String>();
    for (String s: exclusions) {
      excludeList.add(s);
    }
    
    Enumeration<? extends ZipEntry> docEntries= inputZip.entries();
    
    while (docEntries.hasMoreElements()) {
      ZipEntry entry= docEntries.nextElement();
      if (!excludeList.contains(entry.getName())) {
        InputStream in;
        in= inputZip.getInputStream(entry);
        resultZip.putNextEntry(entry);
        writeEntry(in, resultZip);
      }
    }
  }
  
  private void writeEntry(InputStream in, ZipOutputStream out) throws Exception {
    byte[] buf= new byte[1024];
    // Transfer bytes from the file to the ZIP file
    int len;
    while ((len= in.read(buf)) > 0) {
      out.write(buf, 0, len);
    }
    
    // Complete the entry
    out.closeEntry();
    in.close();
  }
  
  private final String EXTRACT_LATEST= "xsl/extract-latest.xsl";
  private final String ROLLBACK_ONE= "xsl/rollback-one-change.xsl";
  private final String SCHEMATRON_CHECKER= "xsl/delta-constraints.xsl";
  private final String SCHEMATRON_REPORTER= "xsl/svrl_error_reporter.xsl";
  
  //takes an InputSource (either styles.xml or content.xml) and writes out the latest version to the OutputStream
  private void extractLastVersion(InputSource in, OutputStream out) throws Exception {
    Transformer t= tf.newTransformer(new StreamSource(this.getClass().getClassLoader().getResourceAsStream(EXTRACT_LATEST)));
    t.setURIResolver(new OdtUriResolver());
    t.transform(new SAXSource(in), new StreamResult(out));
  }
  
  //takes an InputSource (either styles.xml or content.xml), rolls back the last change and writes the result to the OutputStream
  private void rollbackOne(InputSource in, OutputStream out) throws Exception {
    Transformer t= tf.newTransformer(new StreamSource(this.getClass().getClassLoader().getResourceAsStream(ROLLBACK_ONE)));
    t.setURIResolver(new OdtUriResolver());
    t.transform(new SAXSource(in), new StreamResult(out));
  }
  
  
  private final String CONTENT= "content.xml";
  private final String STYLES= "styles.xml";
  
  public void extractLatestVersion(File input, File result) throws Exception {
    ZipFile zip= new ZipFile(input);
    
    InputStream content= zip.getInputStream(zip.getEntry(CONTENT));
    InputStream styles= zip.getInputStream(zip.getEntry(STYLES));
    
    InputSource contentSrc= new InputSource(content);
    contentSrc.setSystemId(input.toURI().toString());
    
    InputSource stylesSrc= new InputSource(styles);
    stylesSrc.setSystemId(input.toURI().toString());
    
    
    ZipOutputStream resultZip= new ZipOutputStream(new FileOutputStream(result));
    copyZipEntries(zip, resultZip, new String[] {CONTENT, STYLES});
    
    resultZip.putNextEntry(new ZipEntry(CONTENT));
    extractLastVersion(contentSrc, resultZip);
    resultZip.closeEntry();
    
    resultZip.putNextEntry(new ZipEntry(STYLES));
    extractLastVersion(stylesSrc, resultZip);
    resultZip.closeEntry();
    
    resultZip.close();
    
  }
  
  public void rollbackLastChange(File input, File result) throws Exception {
    ZipFile zip= new ZipFile(input);
    
    InputStream content= zip.getInputStream(zip.getEntry(CONTENT));
    InputStream styles= zip.getInputStream(zip.getEntry(STYLES));
    
    InputSource contentSrc= new InputSource(content);
    contentSrc.setSystemId(input.toURI().toString());
    
    InputSource stylesSrc= new InputSource(styles);
    stylesSrc.setSystemId(input.toURI().toString());
    
    
    ZipOutputStream resultZip= new ZipOutputStream(new FileOutputStream(result));
    copyZipEntries(zip, resultZip, new String[] {CONTENT, STYLES});
    
    resultZip.putNextEntry(new ZipEntry(CONTENT));
    rollbackOne(contentSrc, resultZip);
    resultZip.closeEntry();
    
    resultZip.putNextEntry(new ZipEntry(STYLES));
    rollbackOne(stylesSrc, resultZip);
    resultZip.closeEntry();
    
    resultZip.close();
  }
  
  /**
   * Provides an XPath/Saxon based equivalent to the DeltaXML XMLComparator.isEqual() and
   * to some extent PipelinedComparator.isEqual(...)
   * @param is1 input stream for the first input tree
   * @param is2 input stream for the second input
   * @return true if equal, false otherwise
   * @throws SaxonApiException when there's a problem invoking Saxon APIs
   */
  public boolean isEqual(InputStream is1, InputStream is2) throws SaxonApiException
  {
    Processor p= new Processor(false);
    XPathCompiler c= p.newXPathCompiler();
    c.declareVariable(new QName("", "a"));
    c.declareVariable(new QName("", "b"));
    c.declareNamespace("fn", "http://www.w3.org/2005/xpath-functions");
    XPathExecutable x=c.compile("fn:deep-equal($a, $b)");
    XPathSelector s= x.load();
    s.setVariable(new QName("", "a"), p.newDocumentBuilder().build(new StreamSource(is1)));
    s.setVariable(new QName("", "b"), p.newDocumentBuilder().build(new StreamSource(is2)));
    XdmAtomicValue result= (XdmAtomicValue)s.evaluateSingle();
    return result.getBooleanValue();
  }
  
  public boolean equalFiles(File in1, File in2, String version, File outputDir) throws Exception {
    
    ZipFile zip1= new ZipFile(in1);
    ZipFile zip2= new ZipFile(in2);
    
    InputStream content1= zip1.getInputStream(zip1.getEntry(CONTENT));
    InputStream content2= zip2.getInputStream(zip2.getEntry(CONTENT));
    
    InputStream styles1= zip1.getInputStream(zip1.getEntry(STYLES));
    InputStream styles2= zip2.getInputStream(zip2.getEntry(STYLES));
    // Disable DeltaXML specific isEqual and compare methods, replaced temporarily with
    // Saxon/XPath2 compare
//    PipelinedComparator pc= new PipelinedComparator();
//    boolean contentEqual= pc.isEqual(content1, content2);

    boolean contentEqual= isEqual(content1, content2);
    
//    if (!contentEqual) {
//      File diffFile= new File(outputDir, version + "-content-diff.xml");
//      //recreate the read-once input streams
//      content1= zip1.getInputStream(zip1.getEntry(CONTENT));
//      content2= zip2.getInputStream(zip2.getEntry(CONTENT));
//      pc.compare(content1, content2, new FileOutputStream(diffFile));
//    }
    
//    boolean stylesEqual= pc.isEqual(styles1, styles2);
    boolean stylesEqual= isEqual(styles1, styles2);

//    if (!stylesEqual) {
//      File diffFile= new File(outputDir, version + "-styles-diff.xml");
//      //recreate the read-once input streams
//      styles1= zip1.getInputStream(zip1.getEntry(STYLES));
//      styles2= zip2.getInputStream(zip2.getEntry(STYLES));
//      pc.compare(styles1, styles2, new FileOutputStream(diffFile));
//    }
    
    
    return contentEqual && stylesEqual;
  }
  
  private boolean rngValid(File f) throws Exception {
    
    String reportFilename; 
    if (f.getName().startsWith("TEST-OUTPUT")) {
      reportFilename=f.getAbsolutePath() + ".rng-validation.txt";
    } else {
      reportFilename= f.getParentFile().getName() + File.separator + "TEST-OUTPUT-" + f.getName() + ".rng-validation.txt"; 
    }
    
    File reportFile= new File(reportFilename);
    
    VerifierErrorHandler errorHandler= new VerifierErrorHandler(new FileOutputStream(reportFile));
    
    verifier.setErrorHandler(errorHandler);
    
    try {
      verifier.verify(f);
    } catch (SAXException e) {}
    
    if (reportFile.length() == 0L) {
      reportFile.delete();
    }
    
    return !errorHandler.errors();
  }
  
  private boolean schematronValid(File f, File report) 
    throws IOException, TransformerException
  {
    
    InputSource inputSrc= new InputSource(f.toURI().toString());
    Transformer t= tf.newTransformer(new StreamSource(this.getClass().getClassLoader().getResourceAsStream(SCHEMATRON_CHECKER)));
    t.setURIResolver(new OdtUriResolver());
    t.transform(new SAXSource(inputSrc), new StreamResult(report));
    try { 
      t= tf.newTransformer(new StreamSource(this.getClass().getClassLoader().getResourceAsStream(SCHEMATRON_REPORTER)));
      t.setURIResolver(new OdtUriResolver());
      t.transform(new StreamSource(report), new StreamResult(System.out));
    } catch (TransformerException te) {
      return false;
    }
    return true;
  }
  
  public static void usage() {
    System.out.println();
    System.out.println("Usage: java -jar track-changes-checker.jar tracked-file.odt version-1.odt ... version-n.odt");
    System.out.println();
  }
  
  private boolean checkVersions(File trackedFile, List<File> versions, String outputPrefix, File outputDir) throws Exception {
    boolean valid= true;
    
    if (verbose) {
      System.out.println(versions.size() + " versions will be extracted...");
    }
    
    File latestWithChanges= trackedFile; 
    
    for (int i= versions.size()-1; i >=0; i--) {
      
      File version= versions.get(i);
      
      File singleFileFormat= new File(version.getParentFile(), (version.getName().startsWith("TEST-OUTPUT") ? "" : "TEST-OUTPUT-") + version.getName() + ".single.xml");
      fileFormatConverter.convert(version, singleFileFormat);
      
      //validate the input version single file format
      boolean rngValid= relaxNGValidation ? rngValid(singleFileFormat) : true;
      if (!rngValid) {
        System.out.println("\tFAIL: input " + version.getParentFile().getName() + File.separator + version.getName() + " failed RelaxNG validation");
      }
      valid= valid && rngValid;
      
      int versionNum= i+1;
      
      if (verbose) {
        System.out.println("Checking (schematron): '" + latestWithChanges.getAbsolutePath() + "'");
      }
      
      String path= latestWithChanges.getAbsolutePath();
      String fileExtension=path.substring(path.length()-4);
      
      singleFileFormat= new File(latestWithChanges.getParentFile(), (latestWithChanges.getName().startsWith("TEST-OUTPUT") ? "" : "TEST-OUTPUT-") + latestWithChanges.getName() + ".single.xml");
      fileFormatConverter.convert(latestWithChanges, singleFileFormat);
      
      //validate the latestWithChanges single file format
      rngValid= relaxNGValidation ? rngValid(singleFileFormat) : true;
      if (!rngValid) {
        System.out.println("\tFAIL: file " + latestWithChanges.getParentFile().getName() + File.separator + latestWithChanges.getName() + " failed RelaxNG validation");
      }
      valid= valid && rngValid;
      
      File schematronReport= new File(singleFileFormat.getAbsolutePath() + ".svrl");
      boolean schematronValid= schematronValidation ? schematronValid(singleFileFormat, schematronReport) : true;
      if (!schematronValid) {
        System.out.println("\tFAIL: " + latestWithChanges.getParentFile().getName() + File.separator + latestWithChanges.getName() + " failed Schematron validation");
      }
      valid= valid && schematronValid;
      
      File extractedVersion= new File(outputDir, outputPrefix + "extracted-version-" + versionNum + fileExtension);
      
      
      if (verbose) {
        System.out.println("Extracting version " + versionNum + " to file '" + extractedVersion.getAbsolutePath() + "'");
      }
      
      extractLatestVersion(latestWithChanges, extractedVersion);
      
      singleFileFormat= new File(extractedVersion.getAbsolutePath() + ".single.xml");
      fileFormatConverter.convert(extractedVersion, singleFileFormat);
      
      //validate the extracted version
      rngValid= relaxNGValidation ? rngValid(singleFileFormat) : true;
      if (!rngValid) {
        System.out.println("\tFAIL: extracted version " + extractedVersion.getParentFile().getName() + File.separator + extractedVersion.getName() + " failed RelaxNG validation");
      }
      valid= valid && rngValid;
      
      if (verbose) {
        System.out.println("Comparing extracted version against '" + version.getAbsolutePath() + "'...");
      }
      
      boolean extractedVersionCorrect= equalFiles(version, extractedVersion, outputPrefix + "version-" + versionNum, outputDir);
      if (!extractedVersionCorrect) {
        System.out.println("\tFAIL: extracted file " + extractedVersion.getParentFile().getName() + File.separator + extractedVersion.getName() + " is not correct");
      }
      valid= valid && extractedVersionCorrect;
      
      if (verbose) {
        System.out.println("Extracted version " + versionNum + " correct? " + extractedVersionCorrect);
      }
      
      if (i > 0) {
        File rolledBack= new File(outputDir, outputPrefix + "tracked-version-" + i + fileExtension);
        rollbackLastChange(latestWithChanges, rolledBack);
        latestWithChanges= rolledBack;
      }
    }
    
    return valid;
  }
  
  
  //method for running as a testNG test from a testng.xml file
  @Test
  @Parameters ({"name", "dir", "trackedFile", "versions"})
  public void testRunner(String name, String dir, String trackedFile, String versions) throws Exception{
    verbose= Boolean.getBoolean("verbose");
    
    System.out.println("\n [TEST] Tracked Changes Verifier Test [" + dir + "::" + name + "]");
    
    File trackedVersion= new File(dir, trackedFile);
    
    StringTokenizer versionTokens= new StringTokenizer(versions, ",");
    List<File> versionFiles= new ArrayList<File>();
    
    while(versionTokens.hasMoreElements()) {
      String filename= versionTokens.nextToken();
      versionFiles.add(new File(dir, filename));
    }
    
    //TODO delete old intermediate files and diff results
    
    try {
      boolean testPassed= checkVersions(trackedVersion, versionFiles, "TEST-OUTPUT-", new File(dir));
      if (testPassed) {
        System.out.println("\tPASSED");
      } else {
        System.out.println("\t*** FAILED");
      }
      if (!testPassed) {
        Assert.fail();
      }
    } catch (Exception e) {
      System.out.println("\t*** FAILED: Exception caught");
      e.printStackTrace();
      throw e;
    }
  }
  
  
  //main method for running on the commandline
  public static void main(String[] args) throws Exception {
    if (args.length < 3) {
      usage();
      System.exit(1);
    }
    
    File trackedFile= new File(args[0]);
    List<File> versions= new ArrayList<File>();
    
    for (int i= 1; i < args.length; i++) {
      versions.add(new File(args[i]));
    }
    
    ChangeTrackingVerifier ctv= new ChangeTrackingVerifier();
    
    System.out.println();
    ctv.verbose=true;
    ctv.checkVersions(trackedFile, versions, "", new File("."));
    
    
  }
  
class VerifierErrorHandler implements ErrorHandler {
    
    OutputStream out;
    boolean error= false;
    
    VerifierErrorHandler(OutputStream stream) {
      out= stream;
    }
    
    public void error(SAXParseException spe) throws SAXException {
      error= true;
      try {
        out.write(("ERROR: " + spe.getMessage() + " l:" + spe.getLineNumber() + " c:" + spe.getColumnNumber() + "\n").getBytes());
      } catch (IOException ioe) {
        //do nothing
      }
    }

    public void fatalError(SAXParseException spe) throws SAXException {
      error= true;
      try {
        out.write(("FATAL ERROR: " + spe.getMessage() + " l:" + spe.getLineNumber() + " c:" + spe.getColumnNumber() + "\n").getBytes());
      } catch (IOException ioe) {
        //do nothing
      }
    }

    public void warning(SAXParseException spe) throws SAXException {
      try {
        out.write(("WARNING: " + spe.getMessage() + "  l:" + spe.getLineNumber() + " c:" + spe.getColumnNumber() + "\n").getBytes());
      } catch (IOException ioe) {
        //do nothing
      }
    }
    
    public boolean errors() {
      return error;
    }
    
    public void reset() {
      error= false;
    }
    
    public void setOutputStream(OutputStream stream) {
      out= stream;
    }
    
  }
  
}
