////////////////////////////////////////////////////////////////////////////////
// checkstyle: Checks Java source code for adherence to a set of rules.
// Copyright (C) 2001-2018 the original author or authors.
//
// This library 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 library 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 library; if not, write to the Free Software
// Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
////////////////////////////////////////////////////////////////////////////////

package com.puppycrawl.tools.checkstyle.ant;

import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Properties;
import java.util.ResourceBundle;
import java.util.stream.Collectors;

import org.apache.tools.ant.AntClassLoader;
import org.apache.tools.ant.BuildException;
import org.apache.tools.ant.DirectoryScanner;
import org.apache.tools.ant.Project;
import org.apache.tools.ant.Task;
import org.apache.tools.ant.taskdefs.LogOutputStream;
import org.apache.tools.ant.types.EnumeratedAttribute;
import org.apache.tools.ant.types.FileSet;
import org.apache.tools.ant.types.Path;
import org.apache.tools.ant.types.Reference;

import com.google.common.io.Closeables;
import com.puppycrawl.tools.checkstyle.Checker;
import com.puppycrawl.tools.checkstyle.ConfigurationLoader;
import com.puppycrawl.tools.checkstyle.DefaultLogger;
import com.puppycrawl.tools.checkstyle.ModuleFactory;
import com.puppycrawl.tools.checkstyle.PackageObjectFactory;
import com.puppycrawl.tools.checkstyle.PropertiesExpander;
import com.puppycrawl.tools.checkstyle.ThreadModeSettings;
import com.puppycrawl.tools.checkstyle.XMLLogger;
import com.puppycrawl.tools.checkstyle.api.AuditListener;
import com.puppycrawl.tools.checkstyle.api.AutomaticBean;
import com.puppycrawl.tools.checkstyle.api.CheckstyleException;
import com.puppycrawl.tools.checkstyle.api.Configuration;
import com.puppycrawl.tools.checkstyle.api.RootModule;
import com.puppycrawl.tools.checkstyle.api.SeverityLevel;
import com.puppycrawl.tools.checkstyle.api.SeverityLevelCounter;

/**
 * An implementation of a ANT task for calling checkstyle. See the documentation
 * of the task for usage.
 * @author Oliver Burn
 * @noinspection ClassLoaderInstantiation
 */
public class CheckstyleAntTask extends Task {

    /** Poor man's enum for an xml formatter. */
    private static final String E_XML = "xml";
    /** Poor man's enum for an plain formatter. */
    private static final String E_PLAIN = "plain";

    /** Suffix for time string. */
    private static final String TIME_SUFFIX = " ms.";

    /** Contains the paths to process. */
    private final List<Path> paths = new ArrayList<>();

    /** Contains the filesets to process. */
    private final List<FileSet> fileSets = new ArrayList<>();

    /** Contains the formatters to log to. */
    private final List<Formatter> formatters = new ArrayList<>();

    /** Contains the Properties to override. */
    private final List<Property> overrideProps = new ArrayList<>();

    /** Class path to locate class files. */
    private Path classpath;

    /** Name of file to check. */
    private String fileName;

    /** Config file containing configuration. */
    private String config;

    /** Whether to fail build on violations. */
    private boolean failOnViolation = true;

    /** Property to set on violations. */
    private String failureProperty;

    /** The name of the properties file. */
    private File properties;

    /** The maximum number of errors that are tolerated. */
    private int maxErrors;

    /** The maximum number of warnings that are tolerated. */
    private int maxWarnings = Integer.MAX_VALUE;

    /**
     * Whether to execute ignored modules - some modules may log above
     * their severity depending on their configuration (e.g. WriteTag) so
     * need to be included
     */
    private boolean executeIgnoredModules;

    ////////////////////////////////////////////////////////////////////////////
    // Setters for ANT specific attributes
    ////////////////////////////////////////////////////////////////////////////

    /**
     * Tells this task to write failure message to the named property when there
     * is a violation.
     * @param propertyName the name of the property to set
     *                      in the event of an failure.
     */
    public void setFailureProperty(String propertyName) {
        failureProperty = propertyName;
    }

    /**
     * Sets flag - whether to fail if a violation is found.
     * @param fail whether to fail if a violation is found
     */
    public void setFailOnViolation(boolean fail) {
        failOnViolation = fail;
    }

    /**
     * Sets the maximum number of errors allowed. Default is 0.
     * @param maxErrors the maximum number of errors allowed.
     */
    public void setMaxErrors(int maxErrors) {
        this.maxErrors = maxErrors;
    }

    /**
     * Sets the maximum number of warnings allowed. Default is
     * {@link Integer#MAX_VALUE}.
     * @param maxWarnings the maximum number of warnings allowed.
     */
    public void setMaxWarnings(int maxWarnings) {
        this.maxWarnings = maxWarnings;
    }

    /**
     * Adds a path.
     * @param path the path to add.
     */
    public void addPath(Path path) {
        paths.add(path);
    }

    /**
     * Adds set of files (nested fileset attribute).
     * @param fileSet the file set to add
     */
    public void addFileset(FileSet fileSet) {
        fileSets.add(fileSet);
    }

    /**
     * Add a formatter.
     * @param formatter the formatter to add for logging.
     */
    public void addFormatter(Formatter formatter) {
        formatters.add(formatter);
    }

    /**
     * Add an override property.
     * @param property the property to add
     */
    public void addProperty(Property property) {
        overrideProps.add(property);
    }

    /**
     * Set the class path.
     * @param classpath the path to locate classes
     */
    public void setClasspath(Path classpath) {
        if (this.classpath == null) {
            this.classpath = classpath;
        }
        else {
            this.classpath.append(classpath);
        }
    }

    /**
     * Set the class path from a reference defined elsewhere.
     * @param classpathRef the reference to an instance defining the classpath
     */
    public void setClasspathRef(Reference classpathRef) {
        createClasspath().setRefid(classpathRef);
    }

    /**
     * Creates classpath.
     * @return a created path for locating classes
     */
    public Path createClasspath() {
        if (classpath == null) {
            classpath = new Path(getProject());
        }
        return classpath.createPath();
    }

    /**
     * Sets file to be checked.
     * @param file the file to be checked
     */
    public void setFile(File file) {
        fileName = file.getAbsolutePath();
    }

    /**
     * Sets configuration file.
     * @param configuration the configuration file, URL, or resource to use
     */
    public void setConfig(String configuration) {
        if (config != null) {
            throw new BuildException("Attribute 'config' has already been set");
        }
        config = configuration;
    }

    /**
     * Sets flag - whether to execute ignored modules.
     * @param omit whether to execute ignored modules
     */
    public void setExecuteIgnoredModules(boolean omit) {
        executeIgnoredModules = omit;
    }

    ////////////////////////////////////////////////////////////////////////////
    // Setters for Root Module's configuration attributes
    ////////////////////////////////////////////////////////////////////////////

    /**
     * Sets a properties file for use instead
     * of individually setting them.
     * @param props the properties File to use
     */
    public void setProperties(File props) {
        properties = props;
    }

    ////////////////////////////////////////////////////////////////////////////
    // The doers
    ////////////////////////////////////////////////////////////////////////////

    @Override
    public void execute() {
        final long startTime = System.currentTimeMillis();

        try {
            // output version info in debug mode
            final ResourceBundle compilationProperties = ResourceBundle
                    .getBundle("checkstylecompilation", Locale.ROOT);
            final String version = compilationProperties
                    .getString("checkstyle.compile.version");
            final String compileTimestamp = compilationProperties
                    .getString("checkstyle.compile.timestamp");
            log("checkstyle version " + version, Project.MSG_VERBOSE);
            log("compiled on " + compileTimestamp, Project.MSG_VERBOSE);

            // Check for no arguments
            if (fileName == null
                    && fileSets.isEmpty()
                    && paths.isEmpty()) {
                throw new BuildException(
                        "Must specify at least one of 'file' or nested 'fileset' or 'path'.",
                        getLocation());
            }
            if (config == null) {
                throw new BuildException("Must specify 'config'.", getLocation());
            }
            realExecute(version);
        }
        finally {
            final long endTime = System.currentTimeMillis();
            log("Total execution took " + (endTime - startTime) + TIME_SUFFIX,
                Project.MSG_VERBOSE);
        }
    }

    /**
     * Helper implementation to perform execution.
     * @param checkstyleVersion Checkstyle compile version.
     */
    private void realExecute(String checkstyleVersion) {
        // Create the root module
        RootModule rootModule = null;
        try {
            rootModule = createRootModule();

            // setup the listeners
            final AuditListener[] listeners = getListeners();
            for (AuditListener element : listeners) {
                rootModule.addListener(element);
            }
            final SeverityLevelCounter warningCounter =
                new SeverityLevelCounter(SeverityLevel.WARNING);
            rootModule.addListener(warningCounter);

            processFiles(rootModule, warningCounter, checkstyleVersion);
        }
        finally {
            destroyRootModule(rootModule);
        }
    }

    /**
     * Destroy root module. This method exists only due to bug in cobertura library
     * https://github.com/cobertura/cobertura/issues/170
     * @param rootModule Root module that was used to process files
     */
    private static void destroyRootModule(RootModule rootModule) {
        if (rootModule != null) {
            rootModule.destroy();
        }
    }

    /**
     * Scans and processes files by means given root module.
     * @param rootModule Root module to process files
     * @param warningCounter Root Module's counter of warnings
     * @param checkstyleVersion Checkstyle compile version
     */
    private void processFiles(RootModule rootModule, final SeverityLevelCounter warningCounter,
            final String checkstyleVersion) {
        final long startTime = System.currentTimeMillis();
        final List<File> files = getFilesToCheck();
        final long endTime = System.currentTimeMillis();
        log("To locate the files took " + (endTime - startTime) + TIME_SUFFIX,
            Project.MSG_VERBOSE);

        log("Running Checkstyle " + checkstyleVersion + " on " + files.size()
                + " files", Project.MSG_INFO);
        log("Using configuration " + config, Project.MSG_VERBOSE);

        final int numErrs;

        try {
            final long processingStartTime = System.currentTimeMillis();
            numErrs = rootModule.process(files);
            final long processingEndTime = System.currentTimeMillis();
            log("To process the files took " + (processingEndTime - processingStartTime)
                + TIME_SUFFIX, Project.MSG_VERBOSE);
        }
        catch (CheckstyleException ex) {
            throw new BuildException("Unable to process files: " + files, ex);
        }
        final int numWarnings = warningCounter.getCount();
        final boolean okStatus = numErrs <= maxErrors && numWarnings <= maxWarnings;

        // Handle the return status
        if (!okStatus) {
            final String failureMsg =
                    "Got " + numErrs + " errors and " + numWarnings
                            + " warnings.";
            if (failureProperty != null) {
                getProject().setProperty(failureProperty, failureMsg);
            }

            if (failOnViolation) {
                throw new BuildException(failureMsg, getLocation());
            }
        }
    }

    /**
     * Creates new instance of the root module.
     * @return new instance of the root module
     */
    private RootModule createRootModule() {
        final RootModule rootModule;
        try {
            final Properties props = createOverridingProperties();
            final ThreadModeSettings threadModeSettings =
                    ThreadModeSettings.SINGLE_THREAD_MODE_INSTANCE;
            final ConfigurationLoader.IgnoredModulesOptions ignoredModulesOptions;
            if (executeIgnoredModules) {
                ignoredModulesOptions = ConfigurationLoader.IgnoredModulesOptions.EXECUTE;
            }
            else {
                ignoredModulesOptions = ConfigurationLoader.IgnoredModulesOptions.OMIT;
            }

            final Configuration configuration = ConfigurationLoader.loadConfiguration(config,
                    new PropertiesExpander(props), ignoredModulesOptions, threadModeSettings);

            final ClassLoader moduleClassLoader =
                Checker.class.getClassLoader();

            final ModuleFactory factory = new PackageObjectFactory(
                    Checker.class.getPackage().getName() + ".", moduleClassLoader);

            rootModule = (RootModule) factory.createModule(configuration.getName());
            rootModule.setModuleClassLoader(moduleClassLoader);

            if (rootModule instanceof Checker) {
                final ClassLoader loader = new AntClassLoader(getProject(),
                        classpath);

                ((Checker) rootModule).setClassLoader(loader);
            }

            rootModule.configure(configuration);
        }
        catch (final CheckstyleException ex) {
            throw new BuildException(String.format(Locale.ROOT, "Unable to create Root Module: "
                    + "config {%s}, classpath {%s}.", config, classpath), ex);
        }
        return rootModule;
    }

    /**
     * Create the Properties object based on the arguments specified
     * to the ANT task.
     * @return the properties for property expansion expansion
     * @throws BuildException if an error occurs
     */
    private Properties createOverridingProperties() {
        final Properties returnValue = new Properties();

        // Load the properties file if specified
        if (properties != null) {
            FileInputStream inStream = null;
            try {
                inStream = new FileInputStream(properties);
                returnValue.load(inStream);
            }
            catch (final IOException ex) {
                throw new BuildException("Error loading Properties file '"
                        + properties + "'", ex, getLocation());
            }
            finally {
                Closeables.closeQuietly(inStream);
            }
        }

        // override with Ant properties like ${basedir}
        final Map<String, Object> antProps = getProject().getProperties();
        for (Map.Entry<String, Object> entry : antProps.entrySet()) {
            final String value = String.valueOf(entry.getValue());
            returnValue.setProperty(entry.getKey(), value);
        }

        // override with properties specified in subelements
        for (Property p : overrideProps) {
            returnValue.setProperty(p.getKey(), p.getValue());
        }

        return returnValue;
    }

    /**
     * Return the list of listeners set in this task.
     * @return the list of listeners.
     */
    private AuditListener[] getListeners() {
        final int formatterCount = Math.max(1, formatters.size());

        final AuditListener[] listeners = new AuditListener[formatterCount];

        // formatters
        try {
            if (formatters.isEmpty()) {
                final OutputStream debug = new LogOutputStream(this, Project.MSG_DEBUG);
                final OutputStream err = new LogOutputStream(this, Project.MSG_ERR);
                listeners[0] = new DefaultLogger(debug, AutomaticBean.OutputStreamOptions.CLOSE,
                        err, AutomaticBean.OutputStreamOptions.CLOSE);
            }
            else {
                for (int i = 0; i < formatterCount; i++) {
                    final Formatter formatter = formatters.get(i);
                    listeners[i] = formatter.createListener(this);
                }
            }
        }
        catch (IOException ex) {
            throw new BuildException(String.format(Locale.ROOT, "Unable to create listeners: "
                    + "formatters {%s}.", formatters), ex);
        }
        return listeners;
    }

    /**
     * Returns the list of files (full path name) to process.
     * @return the list of files included via the fileName, filesets and paths.
     */
    private List<File> getFilesToCheck() {
        final List<File> allFiles = new ArrayList<>();
        if (fileName != null) {
            // oops we've got an additional one to process, don't
            // forget it. No sweat, it's fully resolved via the setter.
            log("Adding standalone file for audit", Project.MSG_VERBOSE);
            allFiles.add(new File(fileName));
        }

        final List<File> filesFromFileSets = scanFileSets();
        allFiles.addAll(filesFromFileSets);

        final List<File> filesFromPaths = scanPaths();
        allFiles.addAll(filesFromPaths);

        return allFiles;
    }

    /**
     * Retrieves all files from the defined paths.
     * @return a list of files defined via paths.
     */
    private List<File> scanPaths() {
        final List<File> allFiles = new ArrayList<>();

        for (int i = 0; i < paths.size(); i++) {
            final Path currentPath = paths.get(i);
            final List<File> pathFiles = scanPath(currentPath, i + 1);
            allFiles.addAll(pathFiles);
        }

        return allFiles;
    }

    /**
     * Scans the given path and retrieves all files for the given path.
     *
     * @param path      A path to scan.
     * @param pathIndex The index of the given path. Used in log messages only.
     * @return A list of files, extracted from the given path.
     */
    private List<File> scanPath(Path path, int pathIndex) {
        final String[] resources = path.list();
        log(pathIndex + ") Scanning path " + path, Project.MSG_VERBOSE);
        final List<File> allFiles = new ArrayList<>();
        int concreteFilesCount = 0;

        for (String resource : resources) {
            final File file = new File(resource);
            if (file.isFile()) {
                concreteFilesCount++;
                allFiles.add(file);
            }
            else {
                final DirectoryScanner scanner = new DirectoryScanner();
                scanner.setBasedir(file);
                scanner.scan();
                final List<File> scannedFiles = retrieveAllScannedFiles(scanner, pathIndex);
                allFiles.addAll(scannedFiles);
            }
        }

        if (concreteFilesCount > 0) {
            log(String.format(Locale.ROOT, "%d) Adding %d files from path %s",
                pathIndex, concreteFilesCount, path), Project.MSG_VERBOSE);
        }

        return allFiles;
    }

    /**
     * Returns the list of files (full path name) to process.
     * @return the list of files included via the filesets.
     */
    protected List<File> scanFileSets() {
        final List<File> allFiles = new ArrayList<>();

        for (int i = 0; i < fileSets.size(); i++) {
            final FileSet fileSet = fileSets.get(i);
            final DirectoryScanner scanner = fileSet.getDirectoryScanner(getProject());
            final List<File> scannedFiles = retrieveAllScannedFiles(scanner, i);
            allFiles.addAll(scannedFiles);
        }

        return allFiles;
    }

    /**
     * Retrieves all matched files from the given scanner.
     *
     * @param scanner  A directory scanner. Note, that {@link DirectoryScanner#scan()}
     *                 must be called before calling this method.
     * @param logIndex A log entry index. Used only for log messages.
     * @return A list of files, retrieved from the given scanner.
     */
    private List<File> retrieveAllScannedFiles(DirectoryScanner scanner, int logIndex) {
        final String[] fileNames = scanner.getIncludedFiles();
        log(String.format(Locale.ROOT, "%d) Adding %d files from directory %s",
            logIndex, fileNames.length, scanner.getBasedir()), Project.MSG_VERBOSE);

        return Arrays.stream(fileNames)
            .map(name -> scanner.getBasedir() + File.separator + name)
            .map(File::new)
            .collect(Collectors.toList());
    }

    /**
     * Poor mans enumeration for the formatter types.
     * @author Oliver Burn
     */
    public static class FormatterType extends EnumeratedAttribute {

        /** My possible values. */
        private static final String[] VALUES = {E_XML, E_PLAIN};

        @Override
        public String[] getValues() {
            return VALUES.clone();
        }

    }

    /**
     * Details about a formatter to be used.
     * @author Oliver Burn
     */
    public static class Formatter {

        /** The formatter type. */
        private FormatterType type;
        /** The file to output to. */
        private File toFile;
        /** Whether or not the write to the named file. */
        private boolean useFile = true;

        /**
         * Set the type of the formatter.
         * @param type the type
         */
        public void setType(FormatterType type) {
            this.type = type;
        }

        /**
         * Set the file to output to.
         * @param destination destination the file to output to
         */
        public void setTofile(File destination) {
            toFile = destination;
        }

        /**
         * Sets whether or not we write to a file if it is provided.
         * @param use whether not not to use provided file.
         */
        public void setUseFile(boolean use) {
            useFile = use;
        }

        /**
         * Creates a listener for the formatter.
         * @param task the task running
         * @return a listener
         * @throws IOException if an error occurs
         */
        public AuditListener createListener(Task task) throws IOException {
            final AuditListener listener;
            if (type != null
                    && E_XML.equals(type.getValue())) {
                listener = createXmlLogger(task);
            }
            else {
                listener = createDefaultLogger(task);
            }
            return listener;
        }

        /**
         * Creates default logger.
         * @param task the task to possibly log to
         * @return a DefaultLogger instance
         * @throws IOException if an error occurs
         */
        private AuditListener createDefaultLogger(Task task)
                throws IOException {
            final AuditListener defaultLogger;
            if (toFile == null || !useFile) {
                defaultLogger = new DefaultLogger(
                    new LogOutputStream(task, Project.MSG_DEBUG),
                        AutomaticBean.OutputStreamOptions.CLOSE,
                        new LogOutputStream(task, Project.MSG_ERR),
                        AutomaticBean.OutputStreamOptions.CLOSE
                );
            }
            else {
                final FileOutputStream infoStream = new FileOutputStream(toFile);
                defaultLogger =
                        new DefaultLogger(infoStream, AutomaticBean.OutputStreamOptions.CLOSE,
                                infoStream, AutomaticBean.OutputStreamOptions.NONE);
            }
            return defaultLogger;
        }

        /**
         * Creates XML logger.
         * @param task the task to possibly log to
         * @return an XMLLogger instance
         * @throws IOException if an error occurs
         */
        private AuditListener createXmlLogger(Task task) throws IOException {
            final AuditListener xmlLogger;
            if (toFile == null || !useFile) {
                xmlLogger = new XMLLogger(new LogOutputStream(task, Project.MSG_INFO),
                        AutomaticBean.OutputStreamOptions.CLOSE);
            }
            else {
                xmlLogger = new XMLLogger(new FileOutputStream(toFile),
                        AutomaticBean.OutputStreamOptions.CLOSE);
            }
            return xmlLogger;
        }

    }

    /**
     * Represents a property that consists of a key and value.
     */
    public static class Property {

        /** The property key. */
        private String key;
        /** The property value. */
        private String value;

        /**
         * Gets key.
         * @return the property key
         */
        public String getKey() {
            return key;
        }

        /**
         * Sets key.
         * @param key sets the property key
         */
        public void setKey(String key) {
            this.key = key;
        }

        /**
         * Gets value.
         * @return the property value
         */
        public String getValue() {
            return value;
        }

        /**
         * Sets value.
         * @param value set the property value
         */
        public void setValue(String value) {
            this.value = value;
        }

        /**
         * Sets the property value from a File.
         * @param file set the property value from a File
         */
        public void setFile(File file) {
            value = file.getAbsolutePath();
        }

    }

    /** Represents a custom listener. */
    public static class Listener {

        /** Class name of the listener class. */
        private String className;

        /**
         * Gets class name.
         * @return the class name
         */
        public String getClassname() {
            return className;
        }

        /**
         * Sets class name.
         * @param name set the class name
         */
        public void setClassname(String name) {
            className = name;
        }

    }

}
