package org.apache.lucene.validation;

/*
 * 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.
 */

import org.apache.tools.ant.BuildException;
import org.apache.tools.ant.Project;
import org.apache.tools.ant.Task;
import org.apache.tools.ant.types.Resource;
import org.apache.tools.ant.types.ResourceCollection;
import org.apache.tools.ant.types.resources.FileResource;
import org.apache.tools.ant.types.resources.Resources;
import org.apache.tools.ant.util.FileNameMapper;
import org.xml.sax.Attributes;
import org.xml.sax.InputSource;
import org.xml.sax.SAXException;
import org.xml.sax.XMLReader;
import org.xml.sax.helpers.DefaultHandler;
import org.xml.sax.helpers.XMLReaderFactory;

import javax.xml.parsers.ParserConfigurationException;

import java.io.BufferedReader;
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.nio.charset.Charset;
import java.nio.charset.CharsetDecoder;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Stack;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * An Ant task to verify that the '/org/name' keys in ivy-versions.properties
 * are sorted lexically and are neither duplicates nor orphans, and that all
 * dependencies in all ivy.xml files use rev="${/org/name}" format.
 */
public class LibVersionsCheckTask extends Task {

  private static final String IVY_XML_FILENAME = "ivy.xml";
  private static final Pattern COORDINATE_KEY_PATTERN = Pattern.compile("(/[^/ \t\f]+/[^=:/ \t\f]+).*");
  private static final Pattern BLANK_OR_COMMENT_LINE_PATTERN = Pattern.compile("[ \t\f]*(?:[#!].*)?");
  private static final Pattern TRAILING_BACKSLASH_PATTERN = Pattern.compile("[^\\\\]*(\\\\+)$");
  private static final Pattern LEADING_WHITESPACE_PATTERN = Pattern.compile("[ \t\f]+(.*)");
  private static final Pattern WHITESPACE_GOODSTUFF_WHITESPACE_BACKSLASH_PATTERN
      = Pattern.compile("[ \t\f]*(.*?)(?:(?<!\\\\)[ \t\f]*)?\\\\");
  private static final Pattern TRAILING_WHITESPACE_BACKSLASH_PATTERN
      = Pattern.compile("(.*?)(?:(?<!\\\\)[ \t\f]*)?\\\\");

  /**
   * All ivy.xml files to check.
   */
  private Resources ivyXmlResources = new Resources();

  /**
   * Centralized Ivy versions properties file
   */
  private File centralizedVersionsFile;

  /**
   * License file mapper.
   */
  private FileNameMapper licenseMapper;

  /**
   * A logging level associated with verbose logging.
   */
  private int verboseLevel = Project.MSG_VERBOSE;

  /**
   * Failure flag.
   */
  private boolean failures;
  
  /**
   * All /org/name version keys found in ivy-versions.properties, and whether they
   * are referenced in any ivy.xml file.
   */
  private Map<String,Boolean> referencedCoordinateKeys = new LinkedHashMap<>();

  /**
   * Adds a set of ivy.xml resources to check.
   */
  public void add(ResourceCollection rc) {
    ivyXmlResources.add(rc);
  }

  public void setVerbose(boolean verbose) {
    verboseLevel = (verbose ? Project.MSG_INFO : Project.MSG_VERBOSE);
  }

  public void setCentralizedVersionsFile(File file) {
    centralizedVersionsFile = file;
  }

  /**
   * Execute the task.
   */
  @Override
  public void execute() throws BuildException {
    log("Starting scan.", verboseLevel);
    long start = System.currentTimeMillis();

    int errors = verifySortedCentralizedVersionsFile() ? 0 : 1;
    int checked = 0;

    @SuppressWarnings("unchecked")
    Iterator<Resource> iter = (Iterator<Resource>)ivyXmlResources.iterator();
    while (iter.hasNext()) {
      final Resource resource = iter.next();
      if ( ! resource.isExists()) {
        throw new BuildException("Resource does not exist: " + resource.getName());
      }
      if ( ! (resource instanceof FileResource)) {
        throw new BuildException("Only filesystem resources are supported: " 
            + resource.getName() + ", was: " + resource.getClass().getName());
      }

      File ivyXmlFile = ((FileResource)resource).getFile();
      try {
        if ( ! checkIvyXmlFile(ivyXmlFile) ) {
          failures = true;
          errors++;
        }
      } catch (Exception e) {
        throw new BuildException("Exception reading file " + ivyXmlFile.getPath(), e);
      }
      checked++;
    }
    log("Checking for orphans in " + centralizedVersionsFile.getName(), verboseLevel);
    for (Map.Entry<String,Boolean> entry : referencedCoordinateKeys.entrySet()) {
      String coordinateKey = entry.getKey();
      boolean isReferenced = entry.getValue();
      if ( ! isReferenced) {
        log("ORPHAN coordinate key '" + coordinateKey + "' in " + centralizedVersionsFile.getName()
            + " is not found in any " + IVY_XML_FILENAME + " file.",
            Project.MSG_ERR);
        failures = true;
        errors++;
      }
    }

    log(String.format(Locale.ROOT, "Checked that %s has lexically sorted "
        + "'/org/name' keys and no duplicates or orphans, and scanned %d %s "
        + "file(s) for rev=\"${/org/name}\" format (in %.2fs.), %d error(s).",
        centralizedVersionsFile.getName(), checked, IVY_XML_FILENAME, 
        (System.currentTimeMillis() - start) / 1000.0, errors),
        errors > 0 ? Project.MSG_ERR : Project.MSG_INFO);

    if (failures) {
      throw new BuildException("Lib versions check failed. Check the logs.");
    }
  }

  /**
   * Returns true if the "/org/name" coordinate keys in ivy-versions.properties
   * are lexically sorted and are not duplicates.
   */
  private boolean verifySortedCentralizedVersionsFile() {
    log("Checking for lexically sorted non-duplicated '/org/name' keys in: " + centralizedVersionsFile, verboseLevel);
    final InputStream stream;
    try {
      stream = new FileInputStream(centralizedVersionsFile);
    } catch (FileNotFoundException e) {
      throw new BuildException("Centralized versions file does not exist: "
          + centralizedVersionsFile.getPath());
    }
    // Properties files are encoded as Latin-1
    final Reader reader = new InputStreamReader(stream, StandardCharsets.ISO_8859_1);
    final BufferedReader bufferedReader = new BufferedReader(reader);
    
    String line = null;
    String currentKey = null;
    String previousKey = null;
    try {
      while (null != (line = readLogicalPropertiesLine(bufferedReader))) {
        final Matcher keyMatcher = COORDINATE_KEY_PATTERN.matcher(line);
        if ( ! keyMatcher.matches()) {
          continue; // Ignore keys that don't look like "/org/name"
        }
        currentKey = keyMatcher.group(1);
        if (null != previousKey) {
          int comparison = currentKey.compareTo(previousKey);
          if (0 == comparison) {
            log("DUPLICATE coordinate key '" + currentKey + "' in " + centralizedVersionsFile.getName(),
                Project.MSG_ERR);
            failures = true;
          } else if (comparison < 0) {
            log("OUT-OF-ORDER coordinate key '" + currentKey + "' in " + centralizedVersionsFile.getName(),
                Project.MSG_ERR);
            failures = true;
          }
        }
        referencedCoordinateKeys.put(currentKey, false);
        previousKey = currentKey;
      }
    } catch (IOException e) {
      throw new BuildException("Exception reading centralized versions file: " 
          + centralizedVersionsFile.getPath(), e);
    } finally {
      try { reader.close(); } catch (IOException e) { }
    }
    return ! failures;
  }

  /**
   * Builds up logical {@link java.util.Properties} lines, composed of one non-blank,
   * non-comment initial line, either:
   * 
   * 1. without a non-escaped trailing slash; or
   * 2. with a non-escaped trailing slash, followed by
   *    zero or more lines with a non-escaped trailing slash, followed by
   *    one or more lines without a non-escaped trailing slash
   *
   * All leading non-escaped whitespace and trailing non-escaped whitespace +
   * non-escaped slash are trimmed from each line before concatenating.
   * 
   * After composing the logical line, escaped characters are un-escaped.
   * 
   * null is returned if there are no lines left to read. 
   */
  private String readLogicalPropertiesLine(BufferedReader reader) throws IOException {
    final StringBuilder logicalLine = new StringBuilder();
    String line;
    do {
      line = reader.readLine();
      if (null == line) { 
        return null;
      }
    } while (BLANK_OR_COMMENT_LINE_PATTERN.matcher(line).matches());

    Matcher backslashMatcher = TRAILING_BACKSLASH_PATTERN.matcher(line); 
    // Check for a non-escaped backslash
    if (backslashMatcher.find() && 1 == (backslashMatcher.group(1).length() % 2)) {
      final Matcher firstLineMatcher = TRAILING_WHITESPACE_BACKSLASH_PATTERN.matcher(line);
      if (firstLineMatcher.matches()) {
        logicalLine.append(firstLineMatcher.group(1)); // trim trailing backslash and any preceding whitespace
      }
      line = reader.readLine();
      while (null != line
             && (backslashMatcher = TRAILING_BACKSLASH_PATTERN.matcher(line)).find()
             && 1 == (backslashMatcher.group(1).length() % 2)) {
        // Trim leading whitespace, the trailing backslash and any preceding whitespace
        final Matcher goodStuffMatcher = WHITESPACE_GOODSTUFF_WHITESPACE_BACKSLASH_PATTERN.matcher(line);
        if (goodStuffMatcher.matches()) {
          logicalLine.append(goodStuffMatcher.group(1));
        }
        line = reader.readLine();
      }
      if (null != line) {
        // line can't have a non-escaped trailing backslash
        final Matcher leadingWhitespaceMatcher = LEADING_WHITESPACE_PATTERN.matcher(line);
        if (leadingWhitespaceMatcher.matches()) {
          line = leadingWhitespaceMatcher.group(1); // trim leading whitespace
        }
        logicalLine.append(line);
      }
    } else {
      logicalLine.append(line);
    }
    // trim non-escaped leading whitespace
    final Matcher leadingWhitespaceMatcher = LEADING_WHITESPACE_PATTERN.matcher(logicalLine);
    final CharSequence leadingWhitespaceStripped = leadingWhitespaceMatcher.matches()
                                                 ? leadingWhitespaceMatcher.group(1)
                                                 : logicalLine;

    // unescape all chars in the logical line
    StringBuilder output = new StringBuilder();
    final int numChars = leadingWhitespaceStripped.length();
    for (int pos = 0 ; pos < numChars - 1 ; ++pos) {
      char ch = leadingWhitespaceStripped.charAt(pos);
      if (ch == '\\') {
        ch = leadingWhitespaceStripped.charAt(++pos); 
      }
      output.append(ch);
    }
    if (numChars > 0) {
      output.append(leadingWhitespaceStripped.charAt(numChars - 1));
    }

    return output.toString();
  }

  /**
   * Check a single ivy.xml file for dependencies' versions in rev="${/org/name}"
   * format.  Returns false if problems are found, true otherwise.
   */
  private boolean checkIvyXmlFile(File ivyXmlFile)
      throws ParserConfigurationException, SAXException, IOException {
    log("Scanning: " + ivyXmlFile.getPath(), verboseLevel);
    XMLReader xmlReader = XMLReaderFactory.createXMLReader();
    DependencyRevChecker revChecker = new DependencyRevChecker(ivyXmlFile); 
    xmlReader.setContentHandler(revChecker);
    xmlReader.setErrorHandler(revChecker);
    xmlReader.parse(new InputSource(ivyXmlFile.getAbsolutePath()));
    return ! revChecker.fail;
  }

  private class DependencyRevChecker extends DefaultHandler {
    private final File ivyXmlFile;
    private final Stack<String> tags = new Stack<>();
    
    public boolean fail = false;

    public DependencyRevChecker(File ivyXmlFile) {
      this.ivyXmlFile = ivyXmlFile;
    }

    @Override
    public void startElement(String uri, String localName, String qName, Attributes attributes) throws SAXException {
      if (localName.equals("dependency") && insideDependenciesTag()) {
        String org = attributes.getValue("org");
        boolean foundAllAttributes = true;
        if (null == org) {
          log("MISSING 'org' attribute on <dependency> in " + ivyXmlFile.getPath(), Project.MSG_ERR);
          fail = true;
          foundAllAttributes = false;
        }
        String name = attributes.getValue("name");
        if (null == name) {
          log("MISSING 'name' attribute on <dependency> in " + ivyXmlFile.getPath(), Project.MSG_ERR);
          fail = true;
          foundAllAttributes = false;
        }
        String rev = attributes.getValue("rev");
        if (null == rev) {
          log("MISSING 'rev' attribute on <dependency> in " + ivyXmlFile.getPath(), Project.MSG_ERR);
          fail = true;
          foundAllAttributes = false;
        }
        if (foundAllAttributes) {
          String coordinateKey = "/" + org + '/' + name;
          String expectedRev = "${" + coordinateKey + '}';
          if ( ! rev.equals(expectedRev)) {
            log("BAD <dependency> 'rev' attribute value '" + rev + "' - expected '" + expectedRev + "'"
                + " in " + ivyXmlFile.getPath(), Project.MSG_ERR);
            fail = true;
          }
          if ( ! referencedCoordinateKeys.containsKey(coordinateKey)) {
            log("MISSING key '" + coordinateKey + "' in " + centralizedVersionsFile.getPath(), Project.MSG_ERR);
            fail = true;
          }
          referencedCoordinateKeys.put(coordinateKey, true);
        }
      }
      tags.push(localName);
    }

    @Override
    public void endElement (String uri, String localName, String qName) throws SAXException {
      tags.pop();
    }

    private boolean insideDependenciesTag() {
      return tags.size() == 2 && tags.get(0).equals("ivy-module") && tags.get(1).equals("dependencies");
    }
  }
}
