/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to you 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.
 */
package org.apache.calcite.test;

import java.io.File;
import java.io.IOException;
import java.io.Writer;
import java.net.URL;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;

import org.apache.calcite.avatica.util.Spaces;
import org.apache.calcite.util.Pair;
import org.apache.calcite.util.Util;
import org.apache.calcite.util.XmlOutput;
import org.junit.Assert;
import org.junit.ComparisonFailure;
import org.w3c.dom.CDATASection;
import org.w3c.dom.Comment;
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.SAXException;

/**
 * A collection of resources used by tests.
 *
 * <p>Loads files containing test input and output into memory. If there are
 * differences, writes out a log file containing the actual output.
 *
 * <p>Typical usage is as follows. A test case class defines a method
 *
 * <blockquote><pre><code>
 * package com.acme.test;
 * &nbsp;
 * public class MyTest extends TestCase {
 *   public DiffRepository getDiffRepos() {
 *     return DiffRepository.lookup(MyTest.class);
 *   }
 * &nbsp;
 *   &#64;Test public void testToUpper() {
 *     getDiffRepos().assertEquals("${result}", "${string}");
 *   }
 * &nbsp;
 *   &#64;Test public void testToLower() {
 *     getDiffRepos().assertEquals("Multi-line\nstring", "${string}");
 *   }
 * }
 * </code></pre></blockquote>
 *
 * <p>There is an accompanying reference file named after the class,
 * <code>src/test/resources/com/acme/test/MyTest.xml</code>:</p>
 *
 * <blockquote><pre><code>
 * &lt;Root&gt;
 *     &lt;TestCase name="testToUpper"&gt;
 *         &lt;Resource name="string"&gt;
 *             &lt;![CDATA[String to be converted to upper case]]&gt;
 *         &lt;/Resource&gt;
 *         &lt;Resource name="result"&gt;
 *             &lt;![CDATA[STRING TO BE CONVERTED TO UPPER CASE]]&gt;
 *         &lt;/Resource&gt;
 *     &lt;/TestCase&gt;
 *     &lt;TestCase name="testToLower"&gt;
 *         &lt;Resource name="result"&gt;
 *             &lt;![CDATA[multi-line
 * string]]&gt;
 *         &lt;/Resource&gt;
 *     &lt;/TestCase&gt;
 * &lt;/Root&gt;
 *
 * </code></pre></blockquote>
 *
 * <p>If any of the test cases fails, a log file is generated, called
 * <code>target/surefire/com/acme/test/MyTest.xml</code>, containing the actual
 * output.</p>
 *
 * <p>(Maven sometimes removes this file; if it is not present, run maven with
 * an extra {@code -X} flag.
 * See <a href="http://jira.codehaus.org/browse/SUREFIRE-846">[SUREFIRE-846]</a>
 * for details.)</p>
 *
 * <p>The log
 * file is otherwise identical to the reference log, so once the log file has
 * been verified, it can simply be copied over to become the new reference
 * log:</p>
 *
 * <blockquote><code>cp target/surefire/com/acme/test/MyTest.xml
 * src/test/resources/com/acme/test/MyTest.xml</code></blockquote>
 *
 * <p>If a resource or test case does not exist, <code>DiffRepository</code>
 * creates them in the log file. Because DiffRepository is so forgiving, it is
 * very easy to create new tests and test cases.</p>
 *
 * <p>The {@link #lookup} method ensures that all test cases share the same
 * instance of the repository. This is important more than one one test case
 * fails. The shared instance ensures that the generated
 * <code>target/surefire/com/acme/test/MyTest.xml</code>
 * file contains the actual for <em>both</em> test cases.
 */
public class DiffRepository {
  //~ Static fields/initializers ---------------------------------------------

/*
      Example XML document:

      <Root>
        <TestCase name="testFoo">
          <Resource name="sql">
            <![CDATA[select from emps]]>
           </Resource>
           <Resource name="plan">
             <![CDATA[MockTableImplRel.FENNEL_EXEC(table=[SALES, EMP])]]>
           </Resource>
         </TestCase>
         <TestCase name="testBar">
           <Resource name="sql">
             <![CDATA[select * from depts where deptno = 10]]>
           </Resource>
           <Resource name="output">
             <![CDATA[10, 'Sales']]>
           </Resource>
         </TestCase>
       </Root>
*/
  private static final String ROOT_TAG = "Root";
  private static final String TEST_CASE_TAG = "TestCase";
  private static final String TEST_CASE_NAME_ATTR = "name";
  private static final String TEST_CASE_OVERRIDES_ATTR = "overrides";
  private static final String RESOURCE_TAG = "Resource";
  private static final String RESOURCE_NAME_ATTR = "name";

  /**
   * Holds one diff-repository per class. It is necessary for all test cases in
   * the same class to share the same diff-repository: if the repository gets
   * loaded once per test case, then only one diff is recorded.
   */
  private static final Map<Class, DiffRepository> MAP_CLASS_TO_REPOSITORY =
      new HashMap<>();

  //~ Instance fields --------------------------------------------------------

  private final DiffRepository baseRepository;
  private final int indent;
  private Document doc;
  private final Element root;
  private final File logFile;
  private final Filter filter;

  //~ Constructors -----------------------------------------------------------

  /**
   * Creates a DiffRepository.
   *
   * @param refFile   Reference file
   * @param logFile   Log file
   * @param baseRepository Parent repository or null
   * @param filter    Filter or null
   */
  private DiffRepository(
      URL refFile,
      File logFile,
      DiffRepository baseRepository,
      Filter filter) {
    this.baseRepository = baseRepository;
    this.filter = filter;
    if (refFile == null) {
      throw new IllegalArgumentException("url must not be null");
    }
    this.logFile = logFile;

    // Load the document.
    DocumentBuilderFactory fac = DocumentBuilderFactory.newInstance();
    try {
      DocumentBuilder docBuilder = fac.newDocumentBuilder();
      try {
        // Parse the reference file.
        this.doc = docBuilder.parse(refFile.openStream());
        // Don't write a log file yet -- as far as we know, it's still
        // identical.
      } catch (IOException e) {
        // There's no reference file. Create and write a log file.
        this.doc = docBuilder.newDocument();
        this.doc.appendChild(
            doc.createElement(ROOT_TAG));
        flushDoc();
      }
      this.root = doc.getDocumentElement();
      if (!root.getNodeName().equals(ROOT_TAG)) {
        throw new RuntimeException("expected root element of type '" + ROOT_TAG
            + "', but found '" + root.getNodeName() + "'");
      }
    } catch (ParserConfigurationException | SAXException e) {
      throw new RuntimeException("error while creating xml parser", e);
    }
    indent = logFile.getPath().contains("RelOptRulesTest")
        || logFile.getPath().contains("SqlToRelConverterTest")
        || logFile.getPath().contains("SqlLimitsTest") ? 4 : 2;
  }

  //~ Methods ----------------------------------------------------------------

  private static URL findFile(Class clazz, final String suffix) {
    // The reference file for class "com.foo.Bar" is "com/foo/Bar.xml"
    String rest = "/" + clazz.getName().replace('.', File.separatorChar)
        + suffix;
    return clazz.getResource(rest);
  }

  /**
   * Expands a string containing one or more variables. (Currently only works
   * if there is one variable.)
   */
  public synchronized String expand(String tag, String text) {
    if (text == null) {
      return null;
    } else if (text.startsWith("${")
        && text.endsWith("}")) {
      final String testCaseName = getCurrentTestCaseName(true);
      final String token = text.substring(2, text.length() - 1);
      if (tag == null) {
        tag = token;
      }
      assert token.startsWith(tag) : "token '" + token
          + "' does not match tag '" + tag + "'";
      String expanded = get(testCaseName, token);
      if (expanded == null) {
        // Token is not specified. Return the original text: this will
        // cause a diff, and the actual value will be written to the
        // log file.
        return text;
      }
      if (filter != null) {
        expanded =
            filter.filter(this, testCaseName, tag, text, expanded);
      }
      return expanded;
    } else {
      // Make sure what appears in the resource file is consistent with
      // what is in the Java. It helps to have a redundant copy in the
      // resource file.
      final String testCaseName = getCurrentTestCaseName(true);
      if (baseRepository == null || baseRepository.get(testCaseName, tag) == null) {
        set(tag, text);
      }
      return text;
    }
  }

  /**
   * Sets the value of a given resource of the current test case.
   *
   * @param resourceName Name of the resource, e.g. "sql"
   * @param value        Value of the resource
   */
  public synchronized void set(String resourceName, String value) {
    assert resourceName != null;
    final String testCaseName = getCurrentTestCaseName(true);
    update(testCaseName, resourceName, value);
  }

  public void amend(String expected, String actual) {
    if (expected.startsWith("${")
        && expected.endsWith("}")) {
      String token = expected.substring(2, expected.length() - 1);
      set(token, actual);
    }
  }

  /**
   * Returns a given resource from a given test case.
   *
   * @param testCaseName Name of test case, e.g. "testFoo"
   * @param resourceName Name of resource, e.g. "sql", "plan"
   * @return The value of the resource, or null if not found
   */
  private synchronized String get(
      final String testCaseName,
      String resourceName) {
    Element testCaseElement = getTestCaseElement(testCaseName, true, null);
    if (testCaseElement == null) {
      if (baseRepository != null) {
        return baseRepository.get(testCaseName, resourceName);
      } else {
        return null;
      }
    }
    final Element resourceElement =
        getResourceElement(testCaseElement, resourceName);
    if (resourceElement != null) {
      return getText(resourceElement);
    }
    return null;
  }

  /**
   * Returns the text under an element.
   */
  private static String getText(Element element) {
    // If there is a <![CDATA[ ... ]]> child, return its text and ignore
    // all other child elements.
    final NodeList childNodes = element.getChildNodes();
    for (int i = 0; i < childNodes.getLength(); i++) {
      Node node = childNodes.item(i);
      if (node instanceof CDATASection) {
        return node.getNodeValue();
      }
    }

    // Otherwise return all the text under this element (including
    // whitespace).
    StringBuilder buf = new StringBuilder();
    for (int i = 0; i < childNodes.getLength(); i++) {
      Node node = childNodes.item(i);
      if (node instanceof Text) {
        buf.append(((Text) node).getWholeText());
      }
    }
    return buf.toString();
  }

  /**
   * Returns the &lt;TestCase&gt; element corresponding to the current test
   * case.
   *
   * @param testCaseName  Name of test case
   * @param checkOverride Make sure that if an element overrides an element in
   *                      a base repository, it has overrides="true"
   * @return TestCase element, or null if not found
   */
  private synchronized Element getTestCaseElement(
      final String testCaseName,
      boolean checkOverride,
      List<Pair<String, Element>> elements) {
    final NodeList childNodes = root.getChildNodes();
    for (int i = 0; i < childNodes.getLength(); i++) {
      Node child = childNodes.item(i);
      if (child.getNodeName().equals(TEST_CASE_TAG)) {
        Element testCase = (Element) child;
        final String name = testCase.getAttribute(TEST_CASE_NAME_ATTR);
        if (testCaseName.equals(name)) {
          if (checkOverride
              && (baseRepository != null)
              && (baseRepository.getTestCaseElement(testCaseName, false, null) != null)
              && !"true".equals(
                  testCase.getAttribute(TEST_CASE_OVERRIDES_ATTR))) {
            throw new RuntimeException(
                "TestCase  '" + testCaseName + "' overrides a "
                + "test case in the base repository, but does "
                + "not specify 'overrides=true'");
          }
          return testCase;
        }
        if (elements != null) {
          elements.add(Pair.of(name, testCase));
        }
      }
    }
    return null;
  }

  /**
   * Returns the name of the current test case by looking up the call stack for
   * a method whose name starts with "test", for example "testFoo".
   *
   * @param fail Whether to fail if no method is found
   * @return Name of current test case, or null if not found
   */
  private String getCurrentTestCaseName(boolean fail) {
    // REVIEW jvs 12-Mar-2006: Too clever by half.  Someone might not know
    // about this and use a private helper method whose name also starts
    // with test. Perhaps just require them to pass in getName() from the
    // calling TestCase's setUp method and store it in a thread-local,
    // failing here if they forgot?

    // Clever, this. Dump the stack and look up it for a method which
    // looks like a test case name, e.g. "testFoo".
    final StackTraceElement[] stackTrace;
    Throwable runtimeException = new Throwable();
    runtimeException.fillInStackTrace();
    stackTrace = runtimeException.getStackTrace();
    for (StackTraceElement stackTraceElement : stackTrace) {
      final String methodName = stackTraceElement.getMethodName();
      if (methodName.startsWith("test")) {
        return methodName;
      }
    }
    if (fail) {
      throw new RuntimeException("no test case on current call stack");
    } else {
      return null;
    }
  }

  public void assertEquals(String tag, String expected, String actual) {
    final String testCaseName = getCurrentTestCaseName(true);
    String expected2 = expand(tag, expected);
    if (expected2 == null) {
      update(testCaseName, expected, actual);
      throw new AssertionError("reference file does not contain resource '"
          + expected + "' for test case '" + testCaseName + "'");
    } else {
      try {
        // TODO jvs 25-Apr-2006:  reuse bulk of
        // DiffTestCase.diffTestLog here; besides newline
        // insensitivity, it can report on the line
        // at which the first diff occurs, which is useful
        // for largish snippets
        String expected2Canonical =
            expected2.replace(Util.LINE_SEPARATOR, "\n");
        String actualCanonical =
            actual.replace(Util.LINE_SEPARATOR, "\n");
        Assert.assertEquals(
            tag,
            expected2Canonical,
            actualCanonical);
      } catch (ComparisonFailure e) {
        amend(expected, actual);
        throw e;
      }
    }
  }

  /**
   * Creates a new document with a given resource.
   *
   * <p>This method is synchronized, in case two threads are running test
   * cases of this test at the same time.
   *
   * @param testCaseName Test case name
   * @param resourceName Resource name
   * @param value        New value of resource
   */
  private synchronized void update(
      String testCaseName,
      String resourceName,
      String value) {
    final List<Pair<String, Element>> map = new ArrayList<>();
    Element testCaseElement = getTestCaseElement(testCaseName, true, map);
    if (testCaseElement == null) {
      testCaseElement = doc.createElement(TEST_CASE_TAG);
      testCaseElement.setAttribute(TEST_CASE_NAME_ATTR, testCaseName);
      Node refElement = ref(testCaseName, map);
      root.insertBefore(testCaseElement, refElement);
    }
    Element resourceElement =
        getResourceElement(testCaseElement, resourceName, true);
    if (resourceElement == null) {
      resourceElement = doc.createElement(RESOURCE_TAG);
      resourceElement.setAttribute(RESOURCE_NAME_ATTR, resourceName);
      testCaseElement.appendChild(resourceElement);
    } else {
      removeAllChildren(resourceElement);
    }
    if (!value.equals("")) {
      resourceElement.appendChild(doc.createCDATASection(value));
    }

    // Write out the document.
    flushDoc();
  }

  private Node ref(String testCaseName, List<Pair<String, Element>> map) {
    if (map.isEmpty()) {
      return null;
    }
    // Compute the position that the new element should be if the map were
    // sorted.
    int i = 0;
    final List<String> names = Pair.left(map);
    for (String s : names) {
      if (s.compareToIgnoreCase(testCaseName) <= 0) {
        ++i;
      }
    }
    // Starting at a proportional position in the list,
    // move forwards through lesser names, then
    // move backwards through greater names.
    //
    // The intended effect is that if the list is already sorted, the new item
    // will end up in exactly the right position, and if the list is not sorted,
    // the new item will end up in approximately the right position.
    while (i < map.size()
        && names.get(i).compareToIgnoreCase(testCaseName) < 0) {
      ++i;
    }
    if (i >= map.size() - 1) {
      return null;
    }
    while (i >= 0 && names.get(i).compareToIgnoreCase(testCaseName) > 0) {
      --i;
    }
    return map.get(i + 1).right;
  }

  /**
   * Flushes the reference document to the file system.
   */
  private void flushDoc() {
    try {
      boolean b = logFile.getParentFile().mkdirs();
      Util.discard(b);
      try (Writer w = Util.printWriter(logFile)) {
        write(doc, w, indent);
      }
    } catch (IOException e) {
      throw new RuntimeException("error while writing test reference log '"
          + logFile + "'", e);
    }
  }

  /**
   * Returns a given resource from a given test case.
   *
   * @param testCaseElement The enclosing TestCase element, e.g. <code>
   *                        &lt;TestCase name="testFoo"&gt;</code>.
   * @param resourceName    Name of resource, e.g. "sql", "plan"
   * @return The value of the resource, or null if not found
   */
  private static Element getResourceElement(
      Element testCaseElement,
      String resourceName) {
    return getResourceElement(testCaseElement, resourceName, false);
  }

  /**
   * Returns a given resource from a given test case.
   *
   * @param testCaseElement The enclosing TestCase element, e.g. <code>
   *                        &lt;TestCase name="testFoo"&gt;</code>.
   * @param resourceName    Name of resource, e.g. "sql", "plan"
   * @param killYoungerSiblings Whether to remove resources with the same
   *                        name and the same parent that are eclipsed
   * @return The value of the resource, or null if not found
   */
  private static Element getResourceElement(Element testCaseElement,
      String resourceName, boolean killYoungerSiblings) {
    final NodeList childNodes = testCaseElement.getChildNodes();
    Element found = null;
    final List<Node> kills = new ArrayList<>();
    for (int i = 0; i < childNodes.getLength(); i++) {
      Node child = childNodes.item(i);
      if (child.getNodeName().equals(RESOURCE_TAG)
          && resourceName.equals(
              ((Element) child).getAttribute(RESOURCE_NAME_ATTR))) {
        if (found == null) {
          found = (Element) child;
        } else if (killYoungerSiblings) {
          kills.add(child);
        }
      }
    }
    for (Node kill : kills) {
      testCaseElement.removeChild(kill);
    }
    return found;
  }

  private static void removeAllChildren(Element element) {
    final NodeList childNodes = element.getChildNodes();
    while (childNodes.getLength() > 0) {
      element.removeChild(childNodes.item(0));
    }
  }

  /**
   * Serializes an XML document as text.
   *
   * <p>FIXME: I'm sure there's a library call to do this, but I'm danged if I
   * can find it. -- jhyde, 2006/2/9.
   */
  private static void write(Document doc, Writer w, int indent) {
    final XmlOutput out = new XmlOutput(w);
    out.setGlob(true);
    out.setIndentString(Spaces.of(indent));
    writeNode(doc, out);
  }

  private static void writeNode(Node node, XmlOutput out) {
    final NodeList childNodes;
    switch (node.getNodeType()) {
    case Node.DOCUMENT_NODE:
      out.print("<?xml version=\"1.0\" ?>\n");
      childNodes = node.getChildNodes();
      for (int i = 0; i < childNodes.getLength(); i++) {
        Node child = childNodes.item(i);
        writeNode(child, out);
      }

      //            writeNode(((Document) node).getDocumentElement(),
      // out);
      break;

    case Node.ELEMENT_NODE:
      Element element = (Element) node;
      final String tagName = element.getTagName();
      out.beginBeginTag(tagName);

      // Attributes.
      final NamedNodeMap attributeMap = element.getAttributes();
      for (int i = 0; i < attributeMap.getLength(); i++) {
        final Node att = attributeMap.item(i);
        out.attribute(
            att.getNodeName(),
            att.getNodeValue());
      }
      out.endBeginTag(tagName);

      // Write child nodes, ignoring attributes but including text.
      childNodes = node.getChildNodes();
      for (int i = 0; i < childNodes.getLength(); i++) {
        Node child = childNodes.item(i);
        if (child.getNodeType() == Node.ATTRIBUTE_NODE) {
          continue;
        }
        writeNode(child, out);
      }
      out.endTag(tagName);
      break;

    case Node.ATTRIBUTE_NODE:
      out.attribute(
          node.getNodeName(),
          node.getNodeValue());
      break;

    case Node.CDATA_SECTION_NODE:
      CDATASection cdata = (CDATASection) node;
      out.cdata(
          cdata.getNodeValue(),
          true);
      break;

    case Node.TEXT_NODE:
      Text text = (Text) node;
      final String wholeText = text.getNodeValue();
      if (!isWhitespace(wholeText)) {
        out.cdata(wholeText, false);
      }
      break;

    case Node.COMMENT_NODE:
      Comment comment = (Comment) node;
      out.print("<!--" + comment.getNodeValue() + "-->\n");
      break;

    default:
      throw new RuntimeException("unexpected node type: " + node.getNodeType()
          + " (" + node + ")");
    }
  }

  private static boolean isWhitespace(String text) {
    for (int i = 0, count = text.length(); i < count; ++i) {
      final char c = text.charAt(i);
      switch (c) {
      case ' ':
      case '\t':
      case '\n':
        break;
      default:
        return false;
      }
    }
    return true;
  }

  /**
   * Finds the repository instance for a given class, with no base
   * repository or filter.
   *
   * @param clazz Test case class
   * @return The diff repository shared between test cases in this class.
   */
  public static DiffRepository lookup(Class clazz) {
    return lookup(clazz, null);
  }

  /**
   * Finds the repository instance for a given class and inheriting from
   * a given repository.
   *
   * @param clazz     Test case class
   * @param baseRepository Base class of test class
   * @return The diff repository shared between test cases in this class.
   */
  public static DiffRepository lookup(
      Class clazz,
      DiffRepository baseRepository) {
    return lookup(clazz, baseRepository, null);
  }

  /**
   * Finds the repository instance for a given class.
   *
   * <p>It is important that all test cases in a class share the same
   * repository instance. This ensures that, if two or more test cases fail,
   * the log file will contains the actual results of both test cases.
   *
   * <p>The <code>baseRepository</code> parameter is useful if the test is an
   * extension to a previous test. If the test class has a base class which
   * also has a repository, specify the repository here. DiffRepository will
   * look for resources in the base class if it cannot find them in this
   * repository. If test resources from test cases in the base class are
   * missing or incorrect, it will not write them to the log file -- you
   * probably need to fix the base test.
   *
   * <p>Use the <code>filter</code> parameter if you expect the test to
   * return results slightly different than in the repository. This happens
   * if the behavior of a derived test is slightly different than a base
   * test. If you do not specify a filter, no filtering will happen.
   *
   * @param clazz     Test case class
   * @param baseRepository Base repository
   * @param filter    Filters each string returned by the repository
   * @return The diff repository shared between test cases in this class.
   */
  public static synchronized DiffRepository lookup(
      Class clazz,
      DiffRepository baseRepository,
      Filter filter) {
    DiffRepository diffRepository = MAP_CLASS_TO_REPOSITORY.get(clazz);
    if (diffRepository == null) {
      final URL refFile = findFile(clazz, ".xml");
      final File logFile =
          new File(refFile.getFile().replace("test-classes", "surefire"));
      diffRepository =
          new DiffRepository(refFile, logFile, baseRepository, filter);
      MAP_CLASS_TO_REPOSITORY.put(clazz, diffRepository);
    }
    return diffRepository;
  }

  /**
   * Callback to filter strings before returning them.
   */
  public interface Filter {
    /**
     * Filters a string.
     *
     * @param diffRepository Repository
     * @param testCaseName   Test case name
     * @param tag            Tag being expanded
     * @param text           Text being expanded
     * @param expanded       Expanded text
     * @return Expanded text after filtering
     */
    String filter(
        DiffRepository diffRepository,
        String testCaseName,
        String tag,
        String text,
        String expanded);
  }
}

// End DiffRepository.java
