/*
 *  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.codehaus.groovy.vmplugin.v9;

import groovy.lang.Tuple2;

import java.io.File;
import java.io.IOException;
import java.net.URI;
import java.nio.file.FileSystem;
import java.nio.file.FileSystemAlreadyExistsException;
import java.nio.file.FileSystems;
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.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;

/**
 * Find classes under the specified package via some classpath entry
 *
 * Usage:
 * <pre><code>
 *   // find classes under `me.sunlan` package via classpath entry(directory) `D:/_APPS/git_apps/java8-labs/out/production/classes/`
 *   ClassFinder.find(URI.create("file:/D:/_APPS/git_apps/java8-labs/out/production/classes/"), "me/sunlan")
 *
 *   // find classes under `groovy.lang` package via classpath entry(jar file) `D:/_DEV/Groovy/groovy-3.0.1/lib/groovy-3.0.1.jar`
 *   ClassFinder.find(URI.create("file:/D:/_DEV/Groovy/groovy-3.0.1/lib/groovy-3.0.1.jar"), "groovy/lang")
 *
 *   // find classes under `java.lang` package via classpath entry(jrt)
 *   ClassFinder.find(URI.create("jrt:/modules/java.base/"), "java/lang")
 *
 *   // find classes under the sub-packages too, e.g. we can get {@link groovy.lang.groovydoc.GroovydocHolder} via the following code
 *   ClassFinder.find(URI.create("file:/D:/_DEV/Groovy/groovy-3.0.1/lib/groovy-3.0.1.jar"), "groovy/lang", true)
 * </code></pre>
 *
 * @since 3.0.2
 */
public class ClassFinder {
    private static final Pattern FILE_SEP_PATTERN = Pattern.compile(Pattern.quote(File.separator));
    private static final Pattern SLASH_PATTERN = Pattern.compile("/");

    /**
     * Returns the found classes
     *
     * @param classpathEntryURI the classpath entry
     * @param packageName the package under which we find classes
     * @return the found classes
     * @since 3.0.2
     */
    public static Map<String, Set<String>> find(URI classpathEntryURI, String packageName) {
        return find(classpathEntryURI, packageName, false);
    }

    /**
     * Returns the found classes
     *
     * @param classpathEntryURI the classpath entry
     * @param packageName the package under which we find classes
     * @param recursive whether to find sub-packages
     * @return the found classes
     * @since 3.0.2
     */
    public static Map<String, Set<String>> find(URI classpathEntryURI, String packageName, boolean recursive) {
        return find(classpathEntryURI, packageName, recursive, true);
    }

    /**
     * Returns the found classes
     *
     * @param classpathEntryURI the classpath entry
     * @param packageName the package under which we find classes
     * @param recursive whether to find sub-packages
     * @param innerClasses whether to find inner classes (class names containing a "$")
     * @return the found classes
     * @since 5.0.0
     */
    public static Map<String, Set<String>> find(URI classpathEntryURI, String packageName, boolean recursive, boolean innerClasses) {
        URI uri;
        String prefix;
        String scheme = classpathEntryURI.getScheme();
        if ("jrt".equals(scheme)) {
            uri = URI.create("jrt:/");
            prefix = classpathEntryURI.getPath().substring(1) + "/";
        } else if ("file".equals(scheme)) {
            Path path = Paths.get(classpathEntryURI);
            if (!Files.exists(path)) {
                throw new IllegalArgumentException(path + " does not exist");
            }

            if (Files.isDirectory(path)) {
                uri = URI.create("file:/");
                prefix = path.toString();
            } else {
                uri = URI.create("jar:" + classpathEntryURI);
                prefix = "";
            }
        } else {
            throw new UnsupportedOperationException(classpathEntryURI + " is not supported");
        }

        return find(uri, prefix, packageName, recursive, innerClasses);
    }

    /**
     * Finds class names under the supplied URI and prefix.
     *
     * @param uri the filesystem, jar, or runtime image URI
     * @param prefix the URI-local path prefix to search from
     * @param packageName the package path to scan
     * @param recursive whether sub-packages should be included
     * @param innerClasses whether inner classes should be included
     * @return the discovered class names mapped to their package paths
     */
    static Map<String, Set<String>> find(URI uri, String prefix, String packageName, boolean recursive, final boolean innerClasses) {
        boolean wfs = "file".equals(uri.getScheme());
        final Pattern sepPattern = wfs ? FILE_SEP_PATTERN : SLASH_PATTERN;

        if (wfs) prefix = prefix.replace("/", File.separator);

        final int prefixElemCnt = prefix.trim().isEmpty() ? 0 : sepPattern.split(prefix).length;

        Map<String, Set<String>> result = new LinkedHashMap<>();
        Tuple2<FileSystem, Boolean> fsMaybeNew = null;
        try {
            fsMaybeNew = maybeNewFileSystem(uri);
            Files.walkFileTree(fsMaybeNew.getV1().getPath(prefix + "/" + packageName), new SimpleFileVisitor<Path>() {
                /**
                 * Continues walking through candidate package directories.
                 *
                 * @param path the directory being visited
                 * @param attrs the directory attributes
                 * @return {@link FileVisitResult#CONTINUE}
                 */
                @Override
                public FileVisitResult preVisitDirectory(Path path, BasicFileAttributes attrs) {
                    return FileVisitResult.CONTINUE;
                }

                /**
                 * Records matching class files under the requested package.
                 *
                 * @param path the file being visited
                 * @param attrs the file attributes
                 * @return {@link FileVisitResult#CONTINUE}
                 */
                @Override
                public FileVisitResult visitFile(Path path, BasicFileAttributes attrs) {
                    String[] pathElems = sepPattern.split(path.toString());
                    int nameCount = pathElems.length;
                    if (nameCount <= prefixElemCnt) {
                        return FileVisitResult.CONTINUE;
                    }

                    String filename = pathElems[nameCount - 1];
                    if ((!innerClasses && filename.contains("$")) || !filename.endsWith(".class") || "module-info.class".equals(filename) || "package-info.class".equals(filename)) {
                        return FileVisitResult.CONTINUE;
                    }

                    String packagePathStr = String.join("/", Arrays.copyOfRange(pathElems, prefixElemCnt + (!wfs && pathElems[0].isEmpty() ? 1 : 0), nameCount - 1));

                    if (recursive || packageName.equals(packagePathStr)) {
                        Set<String> packageNameSet = result.computeIfAbsent(filename.substring(0, filename.lastIndexOf('.')), f -> new HashSet<>(2));
                        packageNameSet.add(packagePathStr);
                    }

                    return FileVisitResult.CONTINUE;
                }
            });
        } catch (UnsupportedOperationException ignored) {
        } catch (Exception e) {
            throw new ClassFindFailedException(
                    String.format("Failed to find classes via uri: %s, prefix: %s, packageName: %s, recursive: %s",
                            uri, prefix, packageName, recursive
                    ), e);
        } finally {
            // we only close file systems we opened
            if (fsMaybeNew != null && fsMaybeNew.getV2()) {
                closeQuietly(fsMaybeNew.getV1());
            }
        }

        return result;
    }

    private static void closeQuietly(FileSystem fs) {
        try {
            fs.close();
        } catch (IOException ignore) {
        }
    }

    private static Tuple2<FileSystem, Boolean> maybeNewFileSystem(URI uri) throws IOException {
        try {
            return new Tuple2<>(FileSystems.newFileSystem(uri, Collections.emptyMap()), true);
        } catch (FileSystemAlreadyExistsException e) {
            return new Tuple2<>(FileSystems.getFileSystem(uri), false);
        }
    }

    private ClassFinder() {}
}
