/*
 * 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.maven.plugins.javadoc;

import java.io.BufferedReader;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.PrintStream;
import java.io.UnsupportedEncodingException;
import java.lang.reflect.Modifier;
import java.net.SocketTimeoutException;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.net.URLClassLoader;
import java.nio.charset.Charset;
import java.nio.charset.IllegalCharsetNameException;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.NoSuchElementException;
import java.util.Properties;
import java.util.Set;
import java.util.StringTokenizer;
import java.util.jar.JarEntry;
import java.util.jar.JarInputStream;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.regex.PatternSyntaxException;
import java.util.stream.Collectors;

import org.apache.http.HttpHeaders;
import org.apache.http.HttpHost;
import org.apache.http.HttpResponse;
import org.apache.http.HttpStatus;
import org.apache.http.auth.AuthScope;
import org.apache.http.auth.Credentials;
import org.apache.http.auth.UsernamePasswordCredentials;
import org.apache.http.client.CredentialsProvider;
import org.apache.http.client.config.CookieSpecs;
import org.apache.http.client.config.RequestConfig;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.protocol.HttpClientContext;
import org.apache.http.config.Registry;
import org.apache.http.config.RegistryBuilder;
import org.apache.http.conn.socket.ConnectionSocketFactory;
import org.apache.http.conn.socket.PlainConnectionSocketFactory;
import org.apache.http.conn.ssl.SSLConnectionSocketFactory;
import org.apache.http.impl.client.BasicCredentialsProvider;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClientBuilder;
import org.apache.http.impl.client.HttpClients;
import org.apache.http.impl.conn.PoolingHttpClientConnectionManager;
import org.apache.http.message.BasicHeader;
import org.apache.maven.plugin.logging.Log;
import org.apache.maven.project.MavenProject;
import org.apache.maven.settings.Proxy;
import org.apache.maven.settings.Settings;
import org.apache.maven.shared.invoker.DefaultInvocationRequest;
import org.apache.maven.shared.invoker.DefaultInvoker;
import org.apache.maven.shared.invoker.InvocationOutputHandler;
import org.apache.maven.shared.invoker.InvocationRequest;
import org.apache.maven.shared.invoker.InvocationResult;
import org.apache.maven.shared.invoker.Invoker;
import org.apache.maven.shared.invoker.MavenInvocationException;
import org.apache.maven.shared.invoker.PrintStreamHandler;
import org.apache.maven.shared.utils.io.DirectoryScanner;
import org.apache.maven.shared.utils.io.FileUtils;
import org.apache.maven.wagon.proxy.ProxyInfo;
import org.apache.maven.wagon.proxy.ProxyUtils;
import org.codehaus.plexus.languages.java.version.JavaVersion;
import org.codehaus.plexus.util.cli.CommandLineException;
import org.codehaus.plexus.util.cli.CommandLineUtils;
import org.codehaus.plexus.util.cli.Commandline;

/**
 * Set of utilities methods for Javadoc.
 *
 * @author <a href="mailto:vincent.siveton@gmail.com">Vincent Siveton</a>
 * @since 2.4
 */
public class JavadocUtil {
    /** The default timeout used when fetching url, i.e. 2000. */
    public static final int DEFAULT_TIMEOUT = 2000;

    /** Error message when VM could not be started using invoker. */
    protected static final String ERROR_INIT_VM =
            "Error occurred during initialization of VM, try to reduce the Java heap size for the MAVEN_OPTS "
                    + "environment variable using -Xms:<size> and -Xmx:<size>.";

    /**
     * Method that removes invalid classpath elements in the specified paths.
     * <b>Note</b>: All elements in {@code paths} could be absolute or relative against the project's base directory.
     * When pruning classpath elements, you can optionally include files in the result, otherwise only directories are
     * permitted.
     *
     * @param project the current Maven project not null
     * @param paths the collection of paths that will be validated
     * @param includeFiles whether to include files in the result as well
     * @return a list of valid classpath elements as absolute paths
     */
    public static Collection<Path> prunePaths(MavenProject project, Collection<String> paths, boolean includeFiles) {
        final Path projectBasedir = project.getBasedir().toPath();

        Set<Path> pruned = new LinkedHashSet<>(paths.size());
        for (String path : paths) {
            if (path == null) {
                continue;
            }

            Path resolvedPath = projectBasedir.resolve(path);

            if (Files.isDirectory(resolvedPath) || includeFiles && Files.isRegularFile(resolvedPath)) {
                pruned.add(resolvedPath.toAbsolutePath());
            }
        }

        return pruned;
    }

    /**
     * Method that removes the invalid classpath directories in the specified directories.
     * <b>Note</b>: All elements in {@code dirs} could be absolute or relative against the project's base directory.
     *
     * @param project the current Maven project not null
     * @param dirs the collection of directories that will be validated
     * @return a list of valid claspath elements as absolute paths
     */
    public static Collection<Path> pruneDirs(MavenProject project, Collection<String> dirs) {
        return prunePaths(project, dirs, false);
    }

    /**
     * Method that removes the invalid files in the specified files. <b>Note</b>: All elements in <code>files</code>
     * should be an absolute <code>String</code> path.
     *
     * @param files the list of <code>String</code> files paths that will be validated.
     * @return a List of valid <code>File</code> objects.
     */
    protected static List<String> pruneFiles(Collection<String> files) {
        List<String> pruned = new ArrayList<>(files.size());
        for (String f : files) {
            if (!shouldPruneFile(f, pruned)) {
                pruned.add(f);
            }
        }

        return pruned;
    }

    /**
     * Determine whether a file should be excluded from the provided list of paths, based on whether it exists and is
     * already present in the list.
     *
     * @param f The files.
     * @param pruned The list of pruned files..
     * @return true if the file could be pruned false otherwise.
     */
    public static boolean shouldPruneFile(String f, List<String> pruned) {
        if (f != null) {
            if (Files.isRegularFile(Paths.get(f)) && !pruned.contains(f)) {
                return false;
            }
        }

        return true;
    }

    /**
     * Method that gets all the source files to be excluded from the javadoc on the given source paths.
     *
     * @param sourcePaths the path to the source files
     * @param excludedPackages the package names to be excluded in the javadoc
     * @return a List of the packages to be excluded in the generated javadoc
     */
    protected static List<String> getExcludedPackages(
            Collection<Path> sourcePaths, Collection<String> excludedPackages) {
        List<String> excludedNames = new ArrayList<>();
        for (Path sourcePath : sourcePaths) {
            excludedNames.addAll(getExcludedPackages(sourcePath, excludedPackages));
        }

        return excludedNames;
    }

    /**
     * Convenience method to wrap a command line option-argument in single quotes (i.e. <code>'</code>). Intended for values which
     * may contain whitespace. <br>
     * Line feeds (i.e. <code>\n</code>) are replaced with spaces, and single quotes are backslash escaped.
     *
     * @param value the option-argument
     * @return quoted option-argument
     */
    protected static String quotedArgument(String value) {
        if (value == null) {
            return null;
        }
        String arg = value;

        List<String> list = Arrays.stream(arg.split("\n")).map(String::trim).collect(Collectors.toList());
        arg = String.join("", list);

        if (arg != null && !arg.isEmpty()) {
            arg = arg.replace("'", "\\'");
            arg = "'" + arg + "'";
        }

        return arg;
    }

    /**
     * Convenience method to format a path argument so that it is properly interpreted by the javadoc tool. Intended for
     * path values which may contain whitespaces.
     *
     * @param value the argument value.
     * @return path argument with quote
     */
    protected static String quotedPathArgument(String value) {
        String path = value;

        if (path != null && !path.isEmpty()) {
            path = path.replace('\\', '/');
            if (path.contains("'")) {
                StringBuilder pathBuilder = new StringBuilder();
                pathBuilder.append('\'');
                String[] split = path.split("'");

                for (int i = 0; i < split.length; i++) {
                    if (i != split.length - 1) {
                        pathBuilder.append(split[i].trim()).append("\\'");
                    } else {
                        pathBuilder.append(split[i]);
                    }
                }
                pathBuilder.append('\'');
                path = pathBuilder.toString();
            } else {
                path = "'" + path + "'";
            }
        }

        return path;
    }

    /**
     * Convenience method that copy all <code>doc-files</code> directories from <code>javadocDir</code> to the
     * <code>outputDirectory</code>.
     *
     * @param outputDirectory the output directory
     * @param javadocDir the javadoc directory
     * @param excludedocfilessubdir the excludedocfilessubdir parameter
     * @throws IOException if any
     * @since 2.5
     */
    protected static void copyJavadocResources(File outputDirectory, File javadocDir, String excludedocfilessubdir)
            throws IOException {
        if (!javadocDir.isDirectory()) {
            return;
        }

        List<String> excludes = new ArrayList<>(Arrays.asList(FileUtils.getDefaultExcludes()));

        if (excludedocfilessubdir != null && !excludedocfilessubdir.isEmpty()) {
            StringTokenizer st = new StringTokenizer(excludedocfilessubdir, ":");
            String current;
            while (st.hasMoreTokens()) {
                current = st.nextToken();
                excludes.add("**/" + current.trim() + "/**");
            }
        }

        List<String> docFiles = FileUtils.getDirectoryNames(
                javadocDir, "resources,**/doc-files", String.join(",", excludes), false, true);
        for (String docFile : docFiles) {
            File docFileOutput = new File(outputDirectory, docFile);
            FileUtils.mkdir(docFileOutput.getAbsolutePath());
            FileUtils.copyDirectoryStructure(new File(javadocDir, docFile), docFileOutput);
            List<String> files = FileUtils.getFileAndDirectoryNames(
                    docFileOutput, String.join(",", excludes), null, true, true, true, true);
            for (String filename : files) {
                File file = new File(filename);

                if (file.isDirectory()) {
                    FileUtils.deleteDirectory(file);
                } else {
                    file.delete();
                }
            }
        }
    }

    /**
     * Method that gets the files or classes that would be included in the javadocs using the subpackages parameter.
     *
     * @param sourceDirectory the directory where the source files are located
     * @param fileList the list of all relative files found in the sourceDirectory
     * @param excludePackages package names to be excluded in the javadoc
     * @return a StringBuilder that contains the appended file names of the files to be included in the javadoc
     */
    protected static List<String> getIncludedFiles(
            File sourceDirectory, String[] fileList, Collection<String> excludePackages) {
        List<String> files = new ArrayList<>();

        List<Pattern> excludePackagePatterns = new ArrayList<>(excludePackages.size());
        for (String excludePackage : excludePackages) {
            excludePackagePatterns.add(Pattern.compile(excludePackage
                    .replace('.', File.separatorChar)
                    .replace("\\", "\\\\")
                    .replace("*", ".+")
                    .concat("[\\\\/][^\\\\/]+\\.java")));
        }

        for (String file : fileList) {
            boolean excluded = false;
            for (Pattern excludePackagePattern : excludePackagePatterns) {
                if (excludePackagePattern.matcher(file).matches()) {
                    excluded = true;
                    break;
                }
            }

            if (!excluded) {
                files.add(file.replace('\\', '/'));
            }
        }

        return files;
    }

    /**
     * Method that gets the complete package names (including subpackages) of the packages that were defined in the
     * excludePackageNames parameter.
     *
     * @param sourceDirectory the directory where the source files are located
     * @param excludePackagenames package names to be excluded in the javadoc
     * @return a List of the packagenames to be excluded
     */
    protected static Collection<String> getExcludedPackages(
            final Path sourceDirectory, Collection<String> excludePackagenames) {
        final String regexFileSeparator = File.separator.replace("\\", "\\\\");

        final Collection<String> fileList = new ArrayList<>();

        try {
            Files.walkFileTree(sourceDirectory, new SimpleFileVisitor<Path>() {
                @Override
                public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
                    if (file.getFileName().toString().endsWith(".java")) {
                        fileList.add(
                                sourceDirectory.relativize(file.getParent()).toString());
                    }
                    return FileVisitResult.CONTINUE;
                }
            });
        } catch (IOException e) {
            // noop
        }

        List<String> files = new ArrayList<>();
        for (String excludePackagename : excludePackagenames) {
            // Usage of wildcard was bad specified and bad implemented, i.e. using String.contains()
            //   without respecting surrounding context
            // Following implementation should match requirements as defined in the examples:
            // - A wildcard at the beginning should match one or more directories
            // - Any other wildcard must match exactly one directory
            Pattern p = Pattern.compile(excludePackagename
                    .replace(".", regexFileSeparator)
                    .replaceFirst("^\\*", ".+")
                    .replace("*", "[^" + regexFileSeparator + "]+"));

            for (String aFileList : fileList) {
                if (p.matcher(aFileList).matches()) {
                    files.add(aFileList.replace(File.separatorChar, '.'));
                }
            }
        }

        return files;
    }

    /**
     * Convenience method that gets the files to be included in the javadoc.
     *
     * @param sourceDirectory the directory where the source files are located
     * @param sourceFileIncludes files to include
     * @param sourceFileExcludes files to exclude
     * @param excludePackages packages to be excluded from the javadocs
     * @return the files from which javadoc should be generated
     */
    protected static List<String> getFilesFromSource(
            File sourceDirectory,
            List<String> sourceFileIncludes,
            List<String> sourceFileExcludes,
            Collection<String> excludePackages) {
        DirectoryScanner ds = new DirectoryScanner();
        if (sourceFileIncludes == null) {
            sourceFileIncludes = Collections.singletonList("**/*.java");
        }
        ds.setIncludes(sourceFileIncludes.toArray(new String[0]));
        if (sourceFileExcludes != null && !sourceFileExcludes.isEmpty()) {
            ds.setExcludes(sourceFileExcludes.toArray(new String[0]));
        }
        ds.setBasedir(sourceDirectory);
        ds.scan();

        String[] fileList = ds.getIncludedFiles();

        List<String> files = new ArrayList<>();
        if (fileList.length != 0) {
            files.addAll(getIncludedFiles(sourceDirectory, fileList, excludePackages));
        }

        return files;
    }

    /**
     * Call the Javadoc tool and parse its output to find its version, i.e.:
     *
     * <pre>
     * javadoc.exe( or.sh ) - J - version
     * </pre>
     *
     * @param javadocExe not null file
     * @return the javadoc version as float
     * @throws IOException if javadocExe is null, doesn't exist or is not a file
     * @throws CommandLineException if any
     * @throws IllegalArgumentException if no output was found in the command line
     * @throws PatternSyntaxException if the output contains a syntax error in the regular-expression pattern.
     * @see #extractJavadocVersion(String)
     */
    protected static JavaVersion getJavadocVersion(File javadocExe)
            throws IOException, CommandLineException, IllegalArgumentException {
        if ((javadocExe == null) || (!javadocExe.exists()) || (!javadocExe.isFile())) {
            throw new IOException("The javadoc executable '" + javadocExe + "' doesn't exist or is not a file. ");
        }

        Commandline cmd = new Commandline();
        cmd.setExecutable(javadocExe.getAbsolutePath());
        cmd.setWorkingDirectory(javadocExe.getParentFile());
        cmd.createArg().setValue("-J-version");

        CommandLineUtils.StringStreamConsumer out = new JavadocOutputStreamConsumer();
        CommandLineUtils.StringStreamConsumer err = new JavadocOutputStreamConsumer();

        int exitCode = CommandLineUtils.executeCommandLine(cmd, out, err);
        String errOutput = err.getOutput(); // non-null
        if (exitCode != 0) {
            StringBuilder msg = new StringBuilder("Exit code: " + exitCode + " - " + errOutput);
            msg.append('\n');
            msg.append("Command line was:").append(CommandLineUtils.toString(cmd.getCommandline()));
            throw new CommandLineException(msg.toString());
        }

        if (!errOutput.isEmpty()) {
            return JavaVersion.parse(extractJavadocVersion(errOutput));
        } else {
            String outOutput = out.getOutput(); // non-null
            if (!outOutput.isEmpty()) {
                return JavaVersion.parse(extractJavadocVersion(outOutput));
            }
        }

        throw new IllegalArgumentException("No output found from the command line 'javadoc -J-version'");
    }

    private static final Pattern EXTRACT_JAVADOC_VERSION_PATTERN =
            Pattern.compile("(?s).*?[^a-zA-Z](([0-9]+\\.?[0-9]*)(\\.[0-9]+)?).*");

    /**
     * Parse the output for 'javadoc -J-version' and return the javadoc version recognized. <br>
     * Here are some output for 'javadoc -J-version' depending the JDK used:
     * <table><caption>Output for 'javadoc -J-version' per JDK</caption>
     * <tr>
     * <th>JDK</th>
     * <th>Output for 'javadoc -J-version'</th>
     * </tr>
     * <tr>
     * <td>Sun 1.4</td>
     * <td>java full version "1.4.2_12-b03"</td>
     * </tr>
     * <tr>
     * <td>Sun 1.5</td>
     * <td>java full version "1.5.0_07-164"</td>
     * </tr>
     * <tr>
     * <td>IBM 1.4</td>
     * <td>javadoc full version "J2RE 1.4.2 IBM Windows 32 build cn1420-20040626"</td>
     * </tr>
     * <tr>
     * <td>IBM 1.5 (French JVM)</td>
     * <td>javadoc version complète de "J2RE 1.5.0 IBM Windows 32 build pwi32pdev-20070426a"</td>
     * </tr>
     * <tr>
     * <td>FreeBSD 1.5</td>
     * <td>java full version "diablo-1.5.0-b01"</td>
     * </tr>
     * <tr>
     * <td>BEA jrockit 1.5</td>
     * <td>java full version "1.5.0_11-b03"</td>
     * </tr>
     * </table>
     *
     * @param output for 'javadoc -J-version'
     * @return the version of the javadoc for the output, only digits and dots
     * @throws PatternSyntaxException if the output doesn't match with the output pattern
     *             {@code (?s).*?[^a-zA-Z]([0-9]+\\.?[0-9]*)(\\.([0-9]+))?.*}.
     * @throws IllegalArgumentException if the output is null
     */
    protected static String extractJavadocVersion(String output) throws IllegalArgumentException {
        if (output == null || output.isEmpty()) {
            throw new IllegalArgumentException("The output could not be null.");
        }

        Pattern pattern = EXTRACT_JAVADOC_VERSION_PATTERN;

        Matcher matcher = pattern.matcher(output);
        if (!matcher.matches()) {
            throw new PatternSyntaxException(
                    "Unrecognized version of Javadoc: '" + output + "'",
                    pattern.pattern(),
                    pattern.toString().length() - 1);
        }

        return matcher.group(1);
    }

    private static final Pattern PARSE_JAVADOC_MEMORY_PATTERN_0 = Pattern.compile("^\\s*(\\d+)\\s*?\\s*$");

    private static final Pattern PARSE_JAVADOC_MEMORY_PATTERN_1 =
            Pattern.compile("^\\s*(\\d+)\\s*k(b)?\\s*$", Pattern.CASE_INSENSITIVE);

    private static final Pattern PARSE_JAVADOC_MEMORY_PATTERN_2 =
            Pattern.compile("^\\s*(\\d+)\\s*m(b)?\\s*$", Pattern.CASE_INSENSITIVE);

    private static final Pattern PARSE_JAVADOC_MEMORY_PATTERN_3 =
            Pattern.compile("^\\s*(\\d+)\\s*g(b)?\\s*$", Pattern.CASE_INSENSITIVE);

    private static final Pattern PARSE_JAVADOC_MEMORY_PATTERN_4 =
            Pattern.compile("^\\s*(\\d+)\\s*t(b)?\\s*$", Pattern.CASE_INSENSITIVE);

    /**
     * Parse a memory string which be used in the JVM arguments <code>-Xms</code> or <code>-Xmx</code>. <br>
     * Here are some supported memory string depending the JDK used:
     * <table><caption>Memory argument support per JDK</caption>
     * <tr>
     * <th>JDK</th>
     * <th>Memory argument support for <code>-Xms</code> or <code>-Xmx</code></th>
     * </tr>
     * <tr>
     * <td>SUN</td>
     * <td>1024k | 128m | 1g | 1t</td>
     * </tr>
     * <tr>
     * <td>IBM</td>
     * <td>1024k | 1024b | 128m | 128mb | 1g | 1gb</td>
     * </tr>
     * <tr>
     * <td>BEA</td>
     * <td>1024k | 1024kb | 128m | 128mb | 1g | 1gb</td>
     * </tr>
     * </table>
     *
     * @param memory the memory to be parsed, not null.
     * @return the memory parsed with a supported unit. If no unit specified in the <code>memory</code> parameter, the
     *         default unit is <code>m</code>. The units <code>g | gb</code> or <code>t | tb</code> will be converted in
     *         <code>m</code>.
     * @throws IllegalArgumentException if the <code>memory</code> parameter is null or doesn't match any pattern.
     */
    protected static String parseJavadocMemory(String memory) throws IllegalArgumentException {
        if (memory == null || memory.isEmpty()) {
            throw new IllegalArgumentException("The memory could not be null.");
        }

        Matcher m0 = PARSE_JAVADOC_MEMORY_PATTERN_0.matcher(memory);
        if (m0.matches()) {
            return m0.group(1) + "m";
        }

        Matcher m1 = PARSE_JAVADOC_MEMORY_PATTERN_1.matcher(memory);
        if (m1.matches()) {
            return m1.group(1) + "k";
        }

        Matcher m2 = PARSE_JAVADOC_MEMORY_PATTERN_2.matcher(memory);
        if (m2.matches()) {
            return m2.group(1) + "m";
        }

        Matcher m3 = PARSE_JAVADOC_MEMORY_PATTERN_3.matcher(memory);
        if (m3.matches()) {
            return (Integer.parseInt(m3.group(1)) * 1024) + "m";
        }

        Matcher m4 = PARSE_JAVADOC_MEMORY_PATTERN_4.matcher(memory);
        if (m4.matches()) {
            return (Integer.parseInt(m4.group(1)) * 1024 * 1024) + "m";
        }

        throw new IllegalArgumentException("Could convert not to a memory size: " + memory);
    }

    /**
     * Validate if a charset is supported on this platform.
     *
     * @param charsetName the charsetName to be check.
     * @return <code>true</code> if the given charset is supported by the JVM, <code>false</code> otherwise.
     */
    protected static boolean validateEncoding(String charsetName) {
        if (charsetName == null || charsetName.isEmpty()) {
            return false;
        }

        try {
            return Charset.isSupported(charsetName);
        } catch (IllegalCharsetNameException e) {
            return false;
        }
    }

    /**
     * Auto-detect the class names of the implementation of <code>com.sun.tools.doclets.Taglet</code> class from a given
     * jar file. <br>
     * <b>Note</b>: <code>JAVA_HOME/lib/tools.jar</code> is a requirement to find
     * <code>com.sun.tools.doclets.Taglet</code> class.
     *
     * @param jarFile not null
     * @return the list of <code>com.sun.tools.doclets.Taglet</code> class names from a given jarFile.
     * @throws IOException if jarFile is invalid or not found, or if the <code>JAVA_HOME/lib/tools.jar</code> is not
     *             found.
     * @throws ClassNotFoundException if any
     * @throws NoClassDefFoundError if any
     */
    protected static List<String> getTagletClassNames(File jarFile)
            throws IOException, ClassNotFoundException, NoClassDefFoundError {
        List<String> classes = getClassNamesFromJar(jarFile);
        URLClassLoader cl;

        // Needed to find com.sun.tools.doclets.Taglet class
        File tools = new File(System.getProperty("java.home"), "../lib/tools.jar");
        if (tools.exists() && tools.isFile()) {
            cl = new URLClassLoader(
                    new URL[] {jarFile.toURI().toURL(), tools.toURI().toURL()}, null);
        } else {
            cl = new URLClassLoader(new URL[] {jarFile.toURI().toURL()}, ClassLoader.getSystemClassLoader());
        }

        List<String> tagletClasses = new ArrayList<>();

        Class<?> tagletClass;

        try {
            tagletClass = cl.loadClass("com.sun.tools.doclets.Taglet");
        } catch (ClassNotFoundException e) {
            tagletClass = cl.loadClass("jdk.javadoc.doclet.Taglet");
        }

        for (String s : classes) {
            Class<?> c = cl.loadClass(s);

            if (tagletClass.isAssignableFrom(c) && !Modifier.isAbstract(c.getModifiers())) {
                tagletClasses.add(c.getName());
            }
        }

        try {
            cl.close();
        } catch (IOException ex) {
            // no big deal
        }

        return tagletClasses;
    }

    /**
     * Copy the given url to the given file.
     *
     * @param url not null url
     * @param file not null file where the url will be created
     * @throws IOException if any
     * @since 2.6
     */
    protected static void copyResource(URL url, File file) throws IOException {
        if (file == null) {
            throw new NullPointerException("The file can't be null.");
        }
        if (url == null) {
            throw new NullPointerException("The url could not be null.");
        }

        FileUtils.copyURLToFile(url, file);
    }

    /**
     * Invoke Maven for the given project file with a list of goals and properties, the output will be in the invokerlog
     * file. <br>
     * <b>Note</b>: the Maven Home should be defined in the <code>maven.home</code> Java system property or defined in
     * <code>M2_HOME</code> system env variables.
     *
     * @param log a logger could be null.
     * @param localRepositoryDir the localRepository not null.
     * @param projectFile a not null project file.
     * @param goals a not null goals list.
     * @param properties the properties for the goals, could be null.
     * @param invokerLog the log file where the invoker will be written, if null using <code>System.out</code>.
     * @param globalSettingsFile reference to settings file, could be null.
     * @param userSettingsFile reference to user settings file, could be null.
     * @param globalToolchainsFile reference to toolchains file, could be null.
     * @param userToolchainsFile reference to user toolchains file, could be null.
     * @throws MavenInvocationException if any
     * @since 2.6
     */
    protected static void invokeMaven(
            Log log,
            File localRepositoryDir,
            File projectFile,
            List<String> goals,
            Properties properties,
            File invokerLog,
            File globalSettingsFile,
            File userSettingsFile,
            File globalToolchainsFile,
            File userToolchainsFile)
            throws MavenInvocationException {
        if (projectFile == null) {
            throw new IllegalArgumentException("projectFile should be not null.");
        }
        if (!projectFile.isFile()) {
            throw new IllegalArgumentException(projectFile.getAbsolutePath() + " is not a file.");
        }
        if (goals == null || goals.isEmpty()) {
            throw new IllegalArgumentException("goals should be not empty.");
        }
        if (localRepositoryDir == null || !localRepositoryDir.isDirectory()) {
            throw new IllegalArgumentException(
                    "localRepositoryDir '" + localRepositoryDir + "' should be a directory.");
        }

        String mavenHome = getMavenHome(log);
        if (mavenHome == null || mavenHome.isEmpty()) {
            String msg = "Could NOT invoke Maven because no Maven Home is defined. You need to have set the M2_HOME "
                    + "system env variable or a maven.home Java system properties.";
            if (log != null) {
                log.error(msg);
            } else {
                System.err.println(msg);
            }
            return;
        }

        Invoker invoker = new DefaultInvoker();
        invoker.setMavenHome(new File(mavenHome));
        invoker.setLocalRepositoryDirectory(localRepositoryDir);

        InvocationRequest request = new DefaultInvocationRequest();
        request.setBaseDirectory(projectFile.getParentFile());
        request.setPomFile(projectFile);
        if (globalSettingsFile != null && globalSettingsFile.isFile()) {
            request.setGlobalSettingsFile(globalSettingsFile);
        }
        if (userSettingsFile != null && userSettingsFile.isFile()) {
            request.setUserSettingsFile(userSettingsFile);
        }
        if (globalToolchainsFile != null && globalToolchainsFile.isFile()) {
            request.setGlobalToolchainsFile(globalToolchainsFile);
        }
        if (userToolchainsFile != null && userToolchainsFile.isFile()) {
            request.setToolchainsFile(userToolchainsFile);
        }
        request.setBatchMode(true);
        if (log != null) {
            request.setDebug(log.isDebugEnabled());
        } else {
            request.setDebug(true);
        }
        request.setGoals(goals);
        if (properties != null) {
            request.setProperties(properties);
        }
        File javaHome = getJavaHome(log);
        if (javaHome != null) {
            request.setJavaHome(javaHome);
        }

        if (log != null && log.isDebugEnabled()) {
            log.debug("Invoking Maven for the goals: " + goals + " with "
                    + (properties == null ? "no properties" : "properties=" + properties));
        }
        InvocationResult result = invoke(log, invoker, request, invokerLog, goals, properties, null);

        if (result.getExitCode() != 0) {
            String invokerLogContent = readFile(invokerLog, "UTF-8");

            // see DefaultMaven
            if (invokerLogContent != null
                    && (!invokerLogContent.contains("Scanning for projects...")
                            || invokerLogContent.contains(OutOfMemoryError.class.getName()))) {
                if (log != null) {
                    log.error("Error occurred during initialization of VM, trying to use an empty MAVEN_OPTS...");

                    if (log.isDebugEnabled()) {
                        log.debug("Reinvoking Maven for the goals: " + goals + " with an empty MAVEN_OPTS...");
                    }
                }
                result = invoke(log, invoker, request, invokerLog, goals, properties, "");
            }
        }

        if (result.getExitCode() != 0) {
            String invokerLogContent = readFile(invokerLog, "UTF-8");

            // see DefaultMaven
            if (invokerLogContent != null
                    && (!invokerLogContent.contains("Scanning for projects...")
                            || invokerLogContent.contains(OutOfMemoryError.class.getName()))) {
                throw new MavenInvocationException(ERROR_INIT_VM);
            }

            throw new MavenInvocationException(
                    "Error when invoking Maven, consult the invoker log file: " + invokerLog.getAbsolutePath());
        }
    }

    /**
     * Read the given file and return the content or null if an IOException occurs.
     *
     * @param javaFile not null
     * @param encoding could be null
     * @return the content with unified line separator of the given javaFile using the given encoding.
     * @see FileUtils#fileRead(File, String)
     * @since 2.6.1
     */
    protected static String readFile(final File javaFile, final String encoding) {
        try {
            return FileUtils.fileRead(javaFile, encoding);
        } catch (IOException e) {
            return null;
        }
    }

    /**
     * Split the given path with colon and semi-colon, to support Solaris and Windows path. Examples:
     *
     * <pre>
     * splitPath( "/home:/tmp" )     = ["/home", "/tmp"]
     * splitPath( "/home;/tmp" )     = ["/home", "/tmp"]
     * splitPath( "C:/home:C:/tmp" ) = ["C:/home", "C:/tmp"]
     * splitPath( "C:/home;C:/tmp" ) = ["C:/home", "C:/tmp"]
     * </pre>
     *
     * @param path which can contain multiple paths separated with a colon (<code>:</code>) or a semi-colon
     *            (<code>;</code>), platform independent. Could be null.
     * @return the path splitted by colon or semi-colon or <code>null</code> if path was <code>null</code>.
     * @since 2.6.1
     */
    protected static String[] splitPath(final String path) {
        if (path == null) {
            return null;
        }

        List<String> subpaths = new ArrayList<>();
        PathTokenizer pathTokenizer = new PathTokenizer(path);
        while (pathTokenizer.hasMoreTokens()) {
            subpaths.add(pathTokenizer.nextToken());
        }

        return subpaths.toArray(new String[0]);
    }

    /**
     * Unify the given path with the current System path separator, to be platform independent. Examples:
     *
     * <pre>
     * unifyPathSeparator( "/home:/tmp" ) = "/home:/tmp" (Solaris box)
     * unifyPathSeparator( "/home:/tmp" ) = "/home;/tmp" (Windows box)
     * </pre>
     *
     * @param path which can contain multiple paths by separating them with a colon (<code>:</code>) or a semi-colon
     *            (<code>;</code>), platform independent. Could be null.
     * @return the same path but separated with the current System path separator or <code>null</code> if path was
     *         <code>null</code>.
     * @since 2.6.1
     * @see #splitPath(String)
     * @see File#pathSeparator
     */
    protected static String unifyPathSeparator(final String path) {
        if (path == null) {
            return null;
        }

        return String.join(File.pathSeparator, splitPath(path));
    }

    // ----------------------------------------------------------------------
    // private methods
    // ----------------------------------------------------------------------

    /**
     * @param jarFile not null
     * @return all class names from the given jar file.
     * @throws IOException if any or if the jarFile is null or doesn't exist.
     */
    private static List<String> getClassNamesFromJar(File jarFile) throws IOException {
        if (jarFile == null || !jarFile.exists() || !jarFile.isFile()) {
            throw new IOException("The jar '" + jarFile + "' doesn't exist or is not a file.");
        }

        List<String> classes = new ArrayList<>();
        Pattern pattern = Pattern.compile("(?i)^(META-INF/versions/(?<v>[0-9]+)/)?(?<n>.+)[.]class$");
        try (JarInputStream jarStream = new JarInputStream(Files.newInputStream(jarFile.toPath()))) {
            for (JarEntry jarEntry = jarStream.getNextJarEntry();
                    jarEntry != null;
                    jarEntry = jarStream.getNextJarEntry()) {
                Matcher matcher = pattern.matcher(jarEntry.getName());
                if (matcher.matches()) {
                    String version = matcher.group("v");
                    if ((version == null || version.isEmpty()) || JavaVersion.JAVA_VERSION.isAtLeast(version)) {
                        String name = matcher.group("n");

                        classes.add(name.replaceAll("/", "\\."));
                    }
                }

                jarStream.closeEntry();
            }
        }

        return classes;
    }

    /**
     * @param log could be null
     * @param invoker not null
     * @param request not null
     * @param invokerLog not null
     * @param goals not null
     * @param properties could be null
     * @param mavenOpts could be null
     * @return the invocation result
     * @throws MavenInvocationException if any
     * @since 2.6
     */
    private static InvocationResult invoke(
            Log log,
            Invoker invoker,
            InvocationRequest request,
            File invokerLog,
            List<String> goals,
            Properties properties,
            String mavenOpts)
            throws MavenInvocationException {
        PrintStream ps;
        OutputStream os = null;
        if (invokerLog != null) {
            if (log != null && log.isDebugEnabled()) {
                log.debug("Using " + invokerLog.getAbsolutePath() + " to log the invoker");
            }

            try {
                if (!invokerLog.exists()) {
                    // noinspection ResultOfMethodCallIgnored
                    invokerLog.getParentFile().mkdirs();
                }
                os = new FileOutputStream(invokerLog);
                ps = new PrintStream(os, true, "UTF-8");
            } catch (FileNotFoundException e) {
                if (log != null && log.isErrorEnabled()) {
                    log.error("FileNotFoundException: " + e.getMessage() + ". Using System.out to log the invoker.");
                }
                ps = System.out;
            } catch (UnsupportedEncodingException e) {
                if (log != null && log.isErrorEnabled()) {
                    log.error("UnsupportedEncodingException: " + e.getMessage()
                            + ". Using System.out to log the invoker.");
                }
                ps = System.out;
            }
        } else {
            if (log != null && log.isDebugEnabled()) {
                log.debug("Using System.out to log the invoker.");
            }

            ps = System.out;
        }

        if (mavenOpts != null) {
            request.setMavenOpts(mavenOpts);
        }

        InvocationOutputHandler outputHandler = new PrintStreamHandler(ps, false);
        request.setOutputHandler(outputHandler);

        try (OutputStream closeMe = os) {
            outputHandler.consumeLine("Invoking Maven for the goals: " + goals + " with "
                    + (properties == null ? "no properties" : "properties=" + properties));
            outputHandler.consumeLine("");
            outputHandler.consumeLine("M2_HOME=" + getMavenHome(log));
            outputHandler.consumeLine("MAVEN_OPTS=" + getMavenOpts(log));
            outputHandler.consumeLine("JAVA_HOME=" + getJavaHome(log));
            outputHandler.consumeLine("JAVA_OPTS=" + getJavaOpts(log));
            outputHandler.consumeLine("");
            return invoker.execute(request);
        } catch (IOException ioe) {
            throw new MavenInvocationException("IOException while consuming invocation output", ioe);
        }
    }

    /**
     * @param log a logger could be null
     * @return the Maven home defined in the <code>maven.home</code> system property
     *         or null if never set.
     * @since 2.6
     */
    private static String getMavenHome(Log log) {
        String mavenHome = System.getProperty("maven.home");

        File m2Home = new File(mavenHome);
        if (!m2Home.exists()) {
            if (log != null && log.isErrorEnabled()) {
                log.error("Cannot find Maven application directory. Specify 'maven.home' system property.");
            }
        }

        return mavenHome;
    }

    /**
     * @param log a logger could be null
     * @return the <code>MAVEN_OPTS</code> env variable value
     * @since 2.6
     */
    private static String getMavenOpts(Log log) {
        return CommandLineUtils.getSystemEnvVars().getProperty("MAVEN_OPTS");
    }

    /**
     * @param log a logger could be null
     * @return the <code>JAVA_HOME</code> from System.getProperty( "java.home" ) By default,
     *         <code>System.getProperty( "java.home" ) = JRE_HOME</code> and <code>JRE_HOME</code> should be in the
     *         <code>JDK_HOME</code>
     * @since 2.6
     */
    private static File getJavaHome(Log log) {
        File javaHome = null;

        String javaHomeValue = CommandLineUtils.getSystemEnvVars().getProperty("JAVA_HOME");

        // if maven.home is set, we can assume JAVA_HOME must be used for testing
        if (System.getProperty("maven.home") == null || javaHomeValue == null) {
            // JEP220 (Java9) restructured the JRE/JDK runtime image
            if (SystemUtils.IS_OS_MAC_OSX || JavaVersion.JAVA_VERSION.isAtLeast("9")) {
                javaHome = SystemUtils.getJavaHome();
            } else {
                javaHome = new File(SystemUtils.getJavaHome(), "..");
            }
        }

        if (javaHome == null || !javaHome.exists()) {
            javaHome = new File(javaHomeValue);
        }

        if (javaHome == null || !javaHome.exists()) {
            if (log != null && log.isErrorEnabled()) {
                log.error("Cannot find Java application directory. Either specify 'java.home' system property, or "
                        + "JAVA_HOME environment variable.");
            }
        }

        return javaHome;
    }

    /**
     * @param log a logger could be null
     * @return the <code>JAVA_OPTS</code> env variable value
     * @since 2.6
     */
    private static String getJavaOpts(Log log) {
        return CommandLineUtils.getSystemEnvVars().getProperty("JAVA_OPTS");
    }

    /**
     * A Path tokenizer takes a path and returns the components that make up that path. The path can use path separators
     * of either ':' or ';' and file separators of either '/' or '\'.
     *
     * @version revision 439418 taken on 2009-09-12 from Ant Project (see
     *          http://svn.apache.org/repos/asf/ant/core/trunk/src/main/org/apache/tools/ant/PathTokenizer.java)
     */
    private static class PathTokenizer {
        /**
         * A tokenizer to break the string up based on the ':' or ';' separators.
         */
        private StringTokenizer tokenizer;

        /**
         * A String which stores any path components which have been read ahead due to DOS filesystem compensation.
         */
        private String lookahead = null;

        /**
         * Flag to indicate whether or not we are running on a platform with a DOS style filesystem
         */
        private boolean dosStyleFilesystem;

        /**
         * Constructs a path tokenizer for the specified path.
         *
         * @param path The path to tokenize. Must not be <code>null</code>.
         */
        PathTokenizer(String path) {
            tokenizer = new StringTokenizer(path, ":;", false);
            dosStyleFilesystem = File.pathSeparatorChar == ';';
        }

        /**
         * Tests if there are more path elements available from this tokenizer's path. If this method returns
         * <code>true</code>, then a subsequent call to nextToken will successfully return a token.
         *
         * @return <code>true</code> if and only if there is at least one token in the string after the current
         *         position; <code>false</code> otherwise.
         */
        public boolean hasMoreTokens() {
            return lookahead != null || tokenizer.hasMoreTokens();
        }

        /**
         * Returns the next path element from this tokenizer.
         *
         * @return the next path element from this tokenizer.
         * @exception NoSuchElementException if there are no more elements in this tokenizer's path.
         */
        public String nextToken() throws NoSuchElementException {
            String token;
            if (lookahead != null) {
                token = lookahead;
                lookahead = null;
            } else {
                token = tokenizer.nextToken().trim();
            }

            if (token.length() == 1
                    && Character.isLetter(token.charAt(0))
                    && dosStyleFilesystem
                    && tokenizer.hasMoreTokens()) {
                // we are on a dos style system so this path could be a drive
                // spec. We look at the next token
                String nextToken = tokenizer.nextToken().trim();
                if (nextToken.startsWith("\\") || nextToken.startsWith("/")) {
                    // we know we are on a DOS style platform and the next path
                    // starts with a slash or backslash, so we know this is a
                    // drive spec
                    token += ":" + nextToken;
                } else {
                    // store the token just read for next time
                    lookahead = nextToken;
                }
            }
            return token.trim();
        }
    }

    /**
     * Ignores line like 'Picked up JAVA_TOOL_OPTIONS: ...' as can happen on CI servers.
     *
     * @author Robert Scholte
     * @since 3.0.1
     */
    protected static class JavadocOutputStreamConsumer extends CommandLineUtils.StringStreamConsumer {
        @Override
        public void consumeLine(String line) {
            if (!line.startsWith("Picked up ")) {
                super.consumeLine(line);
            }
        }
    }

    static List<String> toList(String src) {
        return toList(src, null, null);
    }

    static List<String> toList(String src, String elementPrefix, String elementSuffix) {
        if (src == null || src.isEmpty()) {
            return null;
        }

        List<String> result = new ArrayList<>();

        StringTokenizer st = new StringTokenizer(src, "[,:;]");
        StringBuilder sb = new StringBuilder(256);
        while (st.hasMoreTokens()) {
            sb.setLength(0);
            if (elementPrefix != null && !elementPrefix.isEmpty()) {
                sb.append(elementPrefix);
            }

            sb.append(st.nextToken());

            if (elementSuffix != null && !elementSuffix.isEmpty()) {
                sb.append(elementSuffix);
            }

            result.add(sb.toString().trim());
        }

        return result;
    }

    static <T> List<T> toList(T[] multiple) {
        return toList(null, multiple);
    }

    static <T> List<T> toList(T single, T[] multiple) {
        if (single == null && (multiple == null || multiple.length < 1)) {
            return null;
        }

        List<T> result = new ArrayList<>();
        if (single != null) {
            result.add(single);
        }

        if (multiple != null && multiple.length > 0) {
            result.addAll(Arrays.asList(multiple));
        }

        return result;
    }

    // TODO: deprecate in favor of Path.relativize
    public static String toRelative(File basedir, String absolutePath) {
        String relative;

        absolutePath = absolutePath.replace('\\', '/');
        String basedirPath = basedir.getAbsolutePath().replace('\\', '/');

        if (absolutePath.startsWith(basedirPath)) {
            relative = absolutePath.substring(basedirPath.length());
            if (relative.startsWith("/")) {
                relative = relative.substring(1);
            }
            if (relative.length() <= 0) {
                relative = ".";
            }
        } else {
            relative = absolutePath;
        }

        return relative;
    }

    /**
     * Convenience method to determine that a collection is not empty or null.
     * @param collection the collection to verify
     * @return {@code true} if not {@code null} and not empty, otherwise {@code false}
     */
    public static boolean isNotEmpty(final Collection<?> collection) {
        return collection != null && !collection.isEmpty();
    }

    /**
     * Convenience method to determine that a collection is empty or null.
     * @param collection the collection to verify
     * @return {@code true} if {@code null} or empty, otherwise {@code false}
     */
    public static boolean isEmpty(final Collection<?> collection) {
        return collection == null || collection.isEmpty();
    }

    /**
     * Execute an HTTP request to the given URL, follow redirects, and return the last redirect location. For URLs
     * that aren't http/https, this does nothing and simply returns the given URL unchanged.
     *
     * @param url URL
     * @param settings Maven settings
     * @return final URL after all redirects have been followed
     * @throws IOException if there was an error during the HTTP request
     */
    protected static URL getRedirectUrl(URL url, Settings settings) throws IOException {
        String protocol = url.getProtocol();
        if (!"http".equals(protocol) && !"https".equals(protocol)) {
            return url;
        }

        try (CloseableHttpClient httpClient = createHttpClient(settings, url)) {
            HttpClientContext httpContext = HttpClientContext.create();
            HttpGet httpMethod = new HttpGet(url.toString());
            HttpResponse response = httpClient.execute(httpMethod, httpContext);
            int status = response.getStatusLine().getStatusCode();
            if (status != HttpStatus.SC_OK) {
                throw new FileNotFoundException(
                        "Unexpected HTTP status code " + status + " getting resource " + url.toExternalForm() + ".");
            }

            List<URI> redirects = httpContext.getRedirectLocations();

            if (isEmpty(redirects)) {
                return url;
            } else {
                URI last = redirects.get(redirects.size() - 1);

                // URI must refer to directory, so prevent redirects to index.html
                // see https://issues.apache.org/jira/browse/MJAVADOC-539
                String truncate = "index.html";
                if (last.getPath().endsWith("/" + truncate)) {
                    try {
                        String fixedPath =
                                last.getPath().substring(0, last.getPath().length() - truncate.length());
                        last = new URI(
                                last.getScheme(), last.getAuthority(), fixedPath, last.getQuery(), last.getFragment());
                    } catch (URISyntaxException ex) {
                        // not supposed to happen, but when it does just keep the last URI
                    }
                }
                return last.toURL();
            }
        }
    }

    /**
     * Validates an <code>URL</code> to point to a valid <code>package-list</code> resource.
     *
     * @param url The URL to validate.
     * @param settings The user settings used to configure the connection to the URL or {@code null}.
     * @param validateContent <code>true</code> to validate the content of the <code>package-list</code> resource;
     *            <code>false</code> to only check the existence of the <code>package-list</code> resource.
     * @return <code>true</code> if <code>url</code> points to a valid <code>package-list</code> resource;
     *         <code>false</code> else.
     * @throws IOException if reading the resource fails.
     * @see #createHttpClient(org.apache.maven.settings.Settings, java.net.URL)
     * @since 2.8
     */
    protected static boolean isValidPackageList(URL url, Settings settings, boolean validateContent)
            throws IOException {
        if (url == null) {
            throw new IllegalArgumentException("The url is null");
        }

        try (BufferedReader reader = getReader(url, settings)) {
            if (validateContent) {
                for (String line = reader.readLine(); line != null; line = reader.readLine()) {
                    if (!isValidPackageName(line)) {
                        return false;
                    }
                }
            }
            return true;
        }
    }

    protected static boolean isValidElementList(URL url, Settings settings, boolean validateContent)
            throws IOException {
        if (url == null) {
            throw new IllegalArgumentException("The url is null");
        }

        try (BufferedReader reader = getReader(url, settings)) {
            if (validateContent) {
                for (String line = reader.readLine(); line != null; line = reader.readLine()) {
                    if (line.startsWith("module:")) {
                        continue;
                    }

                    if (!isValidPackageName(line)) {
                        return false;
                    }
                }
            }
            return true;
        }
    }

    private static BufferedReader getReader(URL url, Settings settings) throws IOException {
        BufferedReader reader = null;

        if ("file".equals(url.getProtocol())) {
            // Intentionally using the platform default encoding here since this is what Javadoc uses internally.
            reader = new BufferedReader(new InputStreamReader(url.openStream()));
        } else {
            // http, https...
            final CloseableHttpClient httpClient = createHttpClient(settings, url);

            final HttpGet httpMethod = new HttpGet(url.toString());

            HttpResponse response;
            HttpClientContext httpContext = HttpClientContext.create();
            try {
                response = httpClient.execute(httpMethod, httpContext);
            } catch (SocketTimeoutException e) {
                // could be a sporadic failure, one more retry before we give up
                response = httpClient.execute(httpMethod, httpContext);
            }

            int status = response.getStatusLine().getStatusCode();
            if (status != HttpStatus.SC_OK) {
                throw new FileNotFoundException(
                        "Unexpected HTTP status code " + status + " getting resource " + url.toExternalForm() + ".");
            } else {
                int pos = url.getPath().lastIndexOf('/');
                List<URI> redirects = httpContext.getRedirectLocations();
                if (pos >= 0 && isNotEmpty(redirects)) {
                    URI location = redirects.get(redirects.size() - 1);
                    String suffix = url.getPath().substring(pos);
                    // Redirections shall point to the same file, e.g. /package-list
                    if (!location.getPath().endsWith(suffix)) {
                        throw new FileNotFoundException(url.toExternalForm() + " redirects to "
                                + location.toURL().toExternalForm() + ".");
                    }
                }
            }

            // Intentionally using the platform default encoding here since this is what Javadoc uses internally.
            reader = new BufferedReader(
                    new InputStreamReader(response.getEntity().getContent())) {
                @Override
                public void close() throws IOException {
                    super.close();

                    if (httpMethod != null) {
                        httpMethod.releaseConnection();
                    }
                    if (httpClient != null) {
                        httpClient.close();
                    }
                }
            };
        }

        return reader;
    }

    private static boolean isValidPackageName(String str) {
        if (str == null || str.isEmpty()) {
            // unnamed package is valid (even if bad practice :) )
            return true;
        }

        int idx;
        while ((idx = str.indexOf('.')) != -1) {
            if (!isValidClassName(str.substring(0, idx))) {
                return false;
            }

            str = str.substring(idx + 1);
        }

        return isValidClassName(str);
    }

    private static boolean isValidClassName(String str) {
        if ((str == null || str.isEmpty()) || !Character.isJavaIdentifierStart(str.charAt(0))) {
            return false;
        }

        for (int i = str.length() - 1; i > 0; i--) {
            if (!Character.isJavaIdentifierPart(str.charAt(i))) {
                return false;
            }
        }

        return true;
    }

    /**
     * Creates a new {@code HttpClient} instance.
     *
     * @param settings The settings to use for setting up the client or {@code null}.
     * @param url The {@code URL} to use for setting up the client or {@code null}.
     * @return A new {@code HttpClient} instance.
     * @see #DEFAULT_TIMEOUT
     * @since 2.8
     */
    private static CloseableHttpClient createHttpClient(Settings settings, URL url) {
        HttpClientBuilder builder = HttpClients.custom();

        Registry<ConnectionSocketFactory> csfRegistry = RegistryBuilder.<ConnectionSocketFactory>create()
                .register("http", PlainConnectionSocketFactory.getSocketFactory())
                .register("https", SSLConnectionSocketFactory.getSystemSocketFactory())
                .build();

        builder.setConnectionManager(new PoolingHttpClientConnectionManager(csfRegistry));
        builder.setDefaultRequestConfig(RequestConfig.custom()
                .setSocketTimeout(DEFAULT_TIMEOUT)
                .setConnectTimeout(DEFAULT_TIMEOUT)
                .setCircularRedirectsAllowed(true)
                .setCookieSpec(CookieSpecs.IGNORE_COOKIES)
                .build());

        // Some web servers don't allow the default user-agent sent by httpClient
        builder.setUserAgent("Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.0)");

        // Some server reject requests that do not have an Accept header
        builder.setDefaultHeaders(Collections.singletonList(new BasicHeader(HttpHeaders.ACCEPT, "*/*")));

        if (settings != null && settings.getActiveProxy() != null) {
            Proxy activeProxy = settings.getActiveProxy();

            ProxyInfo proxyInfo = new ProxyInfo();
            proxyInfo.setNonProxyHosts(activeProxy.getNonProxyHosts());

            String activeProxyHost = activeProxy.getHost();
            if (activeProxyHost != null
                    && !activeProxyHost.isEmpty()
                    && (url == null || !ProxyUtils.validateNonProxyHosts(proxyInfo, url.getHost()))) {
                HttpHost proxy = new HttpHost(activeProxy.getHost(), activeProxy.getPort());
                builder.setProxy(proxy);

                String activeProxyUsername = activeProxy.getUsername();
                if (activeProxyUsername != null
                        && !activeProxyUsername.isEmpty()
                        && activeProxy.getPassword() != null) {
                    Credentials credentials =
                            new UsernamePasswordCredentials(activeProxyUsername, activeProxy.getPassword());

                    CredentialsProvider credentialsProvider = new BasicCredentialsProvider();
                    credentialsProvider.setCredentials(AuthScope.ANY, credentials);
                    builder.setDefaultCredentialsProvider(credentialsProvider);
                }
            }
        }
        return builder.build();
    }

    static boolean equalsIgnoreCase(String value, String... strings) {
        for (String s : strings) {
            if (s.equalsIgnoreCase(value)) {
                return true;
            }
        }
        return false;
    }

    static boolean equals(String value, String... strings) {
        for (String s : strings) {
            if (s.equals(value)) {
                return true;
            }
        }
        return false;
    }
}
