/*
 * 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.bookkeeper.tests.integration.utils;

import static org.jboss.shrinkwrap.resolver.api.maven.coordinate.MavenDependencies.createExclusion;

import com.google.common.collect.Lists;
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import groovy.lang.Closure;
import java.io.Closeable;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.lang.invoke.MethodHandles;
import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.net.URL;
import java.net.URLClassLoader;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import java.util.zip.GZIPInputStream;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.compress.archivers.ArchiveStreamFactory;
import org.apache.commons.compress.archivers.tar.TarArchiveEntry;
import org.apache.commons.compress.archivers.tar.TarArchiveInputStream;
import org.apache.commons.io.FileUtils;
import org.apache.commons.io.IOUtils;
import org.jboss.shrinkwrap.resolver.api.maven.ConfigurableMavenResolverSystem;
import org.jboss.shrinkwrap.resolver.api.maven.Maven;
import org.jboss.shrinkwrap.resolver.api.maven.MavenResolvedArtifact;
import org.jboss.shrinkwrap.resolver.api.maven.ScopeType;
import org.jboss.shrinkwrap.resolver.api.maven.coordinate.MavenCoordinate;
import org.jboss.shrinkwrap.resolver.api.maven.coordinate.MavenDependencies;
import org.jboss.shrinkwrap.resolver.api.maven.coordinate.MavenDependency;

/**
 * A maven class loader for resolving and loading maven artifacts.
 */
@Slf4j
public class MavenClassLoader implements AutoCloseable {
    private static ScheduledExecutorService delayedCloseExecutor = createExecutorThatShutsDownIdleThreads();

    private static ScheduledExecutorService createExecutorThatShutsDownIdleThreads() {
        ScheduledExecutorService executor = Executors.newScheduledThreadPool(1);
        // Cast to ThreadPoolExecutor to access additional configuration methods
        ThreadPoolExecutor poolExecutor = (ThreadPoolExecutor) executor;
        // Set the timeout for idle threads
        poolExecutor.setKeepAliveTime(10, TimeUnit.SECONDS);
        // Allow core threads to time out
        poolExecutor.allowCoreThreadTimeOut(true);
        return executor;
    }

    private static List<File> currentVersionLibs;

    private MavenClassLoader(ClassLoader cl) {
        this.classloader = cl;
    }

    private final ClassLoader classloader;

    public static MavenClassLoader forArtifact(String repo, String mainArtifact) throws Exception {
        return createClassLoader(Maven.configureResolver().withRemoteRepo("custom", repo, "default"),
                mainArtifact);
    }

    public static MavenClassLoader forArtifact(String mainArtifact) throws Exception {
        return createClassLoader(Maven.configureResolver(), mainArtifact);
    }

    private static MavenClassLoader createClassLoader(ConfigurableMavenResolverSystem resolver,
                                                      String mainArtifact) throws Exception {
        Optional<String> slf4jVersion = Arrays.stream(resolver.resolve(mainArtifact)
                        .withTransitivity().asResolvedArtifact())
                .filter((a) -> a.getCoordinate().getGroupId().equals("org.slf4j"))
                .map((a) -> a.getCoordinate().getVersion())
                .findFirst();

        MavenDependency dependency = MavenDependencies.createDependency(mainArtifact, ScopeType.COMPILE, false,
                createExclusion("log4j", "log4j"),
                createExclusion("org.slf4j", "slf4j-log4j12"),
                createExclusion("ch.qos.reload4j", "log4j"),
                createExclusion("org.slf4j", "slf4j-reload4j"),
                createExclusion("org.apache.logging.log4j", "*")
                );
        List<MavenDependency> deps = Lists.newArrayList(
                dependency);
        if (slf4jVersion.isPresent()) {
            deps.add(MavenDependencies.createDependency("org.slf4j:slf4j-simple:" + slf4jVersion.get(),
                    ScopeType.COMPILE, false));
            deps.add(MavenDependencies.createDependency("org.slf4j:jcl-over-slf4j:" + slf4jVersion.get(),
                    ScopeType.COMPILE, false));
        }

        MavenResolvedArtifact[] resolvedArtifact = resolver.addDependencies(deps.toArray(new MavenDependency[0]))
                .resolve().withTransitivity().asResolvedArtifact();
        File[] files = Arrays.stream(resolvedArtifact)
                .filter((a) -> {
                    MavenCoordinate c = a.getCoordinate();
                    // exclude log4j
                    if (c.getGroupId().equals("org.apache.logging.log4j") || c.getGroupId().equals("log4j")
                            || c.getGroupId().equals("ch.qos.reload4j")
                            || c.getGroupId().equals("commons-logging")) {
                        return false;
                    }
                    if (c.getArtifactId().contains("log4j") || c.getArtifactId().contains("commons-logging")) {
                        return false;
                    }
                    return true;
                }).map(MavenResolvedArtifact::asFile)
                .collect(Collectors.toList())
                .toArray(new File[0]);
            return createClassLoader(files);
    }

    private static MavenClassLoader createClassLoader(File[] jars) {
        final ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();
        URLClassLoader cl = new URLClassLoader(Arrays.stream(jars)
                .map((f) -> {
                    try {
                        return f.toURI().toURL();
                    } catch (Throwable t) {
                        throw new RuntimeException(t);
                    }
                })
                .toArray(URL[]::new),
                systemClassLoader) {

            @Override
            protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
                Class<?> loadedClass = findLoadedClass(name);
                if (loadedClass == null) {
                    try {
                        loadedClass = findClass(name);
                    } catch (ClassNotFoundException ignored) {
                        // never load these classes from the parent classloader
                        if (name.startsWith("org.apache")
                                || name.startsWith("org.slf4j")
                                || name.startsWith("log4j")) {
                            throw ignored;
                        }
                    }
                    if (loadedClass == null) {
                        try {
                            loadedClass = systemClassLoader.loadClass(name);
                        } catch (ClassNotFoundException e) {
                        }
                    }
                }
                if (resolve && loadedClass != null) {
                    resolveClass(loadedClass);
                }
                return loadedClass;
            }
        };
        return new MavenClassLoader(cl);
    }

    public static MavenClassLoader forBookKeeperVersion(String version) throws Exception {
        if (version.equals(BookKeeperClusterUtils.CURRENT_VERSION)) {
            return forBookkeeperCurrentVersion();
        }
        return forArtifact("org.apache.bookkeeper:bookkeeper-server:" + version);
    }

    private static synchronized MavenClassLoader forBookkeeperCurrentVersion() throws Exception {
        if (currentVersionLibs == null) {
            final String version = BookKeeperClusterUtils.CURRENT_VERSION;
            String rootDirectory = System.getenv("GITHUB_WORKSPACE");
            if (rootDirectory == null) {
                File gitDirectory = findGitRoot(new File("."));
                if (gitDirectory != null) {
                    rootDirectory = gitDirectory.getAbsolutePath();
                } else {
                    rootDirectory = System.getProperty("maven.buildDirectory", ".") + "/../../../..";
                }
            }
            final String artifactName = "bookkeeper-server-" + version + "-bin";
            final Path tarFile = Paths.get(rootDirectory,
                    "bookkeeper-dist", "server", "target", artifactName + ".tar.gz").toAbsolutePath();
            final File tempDir = new File(System.getProperty("maven.buildDirectory", "target"));
            extractTarGz(tarFile.toFile(), tempDir);
            List<File> jars = new ArrayList<>();
            Files.list(Paths.get(tempDir.getAbsolutePath(), "bookkeeper-server-" + version, "lib"))
                    .forEach(path -> {
                        jars.add(path.toFile());
                    });
            currentVersionLibs = jars;
        }
        return createClassLoader(currentVersionLibs.toArray(new File[]{}));
    }

    private static File findGitRoot(File currentDir) {
        while (currentDir != null) {
            if (new File(currentDir, ".git").exists()) {
                return currentDir;
            }
            currentDir = currentDir.getParentFile();
        }
        return null;
    }

    public Object callStaticMethod(String className, String methodName, ArrayList<?> args) throws Exception {
        Class<?> klass = Class.forName(className, true, classloader);

        try {
            Class<?>[] paramTypes = args.stream().map((a) -> a.getClass()).toArray(Class[]::new);
            return klass.getMethod(methodName, paramTypes).invoke(null, args.stream().toArray(Object[]::new));
        } catch (NoSuchMethodException nsme) {
            // maybe the params are primitives
            Class<?>[] paramTypes = args.stream().map((a) -> {
                    Class<?> k = a.getClass();
                    try {
                        Object type = k.getField("TYPE").get(null);
                        if (type instanceof Class<?>) {
                            return (Class<?>) type;
                        } else {
                            return k;
                        }
                    } catch (IllegalAccessException | NoSuchFieldException nsfe) {
                        return k;
                    }
                }).toArray(Class[]::new);
            return klass.getMethod(methodName, paramTypes).invoke(null, args.stream().toArray(Object[]::new));
        }
    }

    public Object createCallback(String interfaceName, Closure closure) throws Exception {
        final Constructor<MethodHandles.Lookup> constructor = MethodHandles.Lookup.class.getDeclaredConstructor(
                Class.class, int.class);
        constructor.setAccessible(true);
        return Proxy.newProxyInstance(classloader,
                                      new Class<?>[]{ Class.forName(interfaceName, true, classloader) },
                                      new InvocationHandler() {

                                          @Override
                                          public Object invoke(Object proxy, Method m, Object[] args) throws Throwable {
                                              if (args.length == closure.getMaximumNumberOfParameters()) {
                                                  return closure.call(args);
                                              } else {
                                                  final Class<?> declaringClass = m.getDeclaringClass();
                                                  return constructor.newInstance(
                                                      declaringClass, MethodHandles.Lookup.PRIVATE)
                                                      .unreflectSpecial(m, declaringClass)
                                                      .bindTo(proxy)
                                                      .invokeWithArguments(args);
                                              }
                                          }
                                      });
    }

    public Object newInstance(String className, Object... args) throws Exception {
        Class<?> klass = Class.forName(className, true, classloader);
        return klass
                .getConstructor(Arrays.stream(args).map((a) ->
                                a.getClass())
                        .toArray(Class[]::new))
                .newInstance(args);
    }

    public Object newBookKeeper(String zookeeper) throws Exception {
        ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader();
        try {
            Thread.currentThread().setContextClassLoader(classloader);
            Class<?> clientConfigurationClass = Class
                    .forName("org.apache.bookkeeper.conf.ClientConfiguration", true, classloader);
            Object clientConfiguration = newInstance("org.apache.bookkeeper.conf.ClientConfiguration");
            clientConfigurationClass
                    .getMethod("setZkServers", String.class)
                    .invoke(clientConfiguration, zookeeper);

            // relax timeouts in order to get tests passing in limited environments
            clientConfigurationClass
                    .getMethod("setReadTimeout", int.class)
                    .invoke(clientConfiguration, 15);

            clientConfigurationClass
                    .getMethod("setZkTimeout", int.class)
                    .invoke(clientConfiguration, 30_000);
            Class<?> klass = Class.forName("org.apache.bookkeeper.client.BookKeeper", true, classloader);
            return klass
                    .getConstructor(clientConfigurationClass)
                    .newInstance(clientConfiguration);
        } finally {
            Thread.currentThread().setContextClassLoader(contextClassLoader);
        }
    }

    public Object digestType(String type) throws Exception {
        String className = "org.apache.bookkeeper.client.BookKeeper$DigestType";
        for (Object o : classloader.loadClass(className).getEnumConstants()) {
            if (o.toString().equals(type)) {
                return o;
            }
        }
        throw new ClassNotFoundException("No such digest type " + type);
    }

    @Override
    public void close() throws Exception {
        if (classloader instanceof Closeable) {
            // delay closing the classloader so that currently executing asynchronous tasks can complete
            delayedCloseExecutor.schedule(() -> {
                try {
                    ((Closeable) classloader).close();
                } catch (Exception e) {
                    log.error("Failed to close classloader", e);
                }
            }, 5, TimeUnit.SECONDS);
        }
    }

    @SuppressFBWarnings("RV_RETURN_VALUE_IGNORED_BAD_PRACTICE")
    private static void extractTarGz(File tarGz, File output) throws Exception {
        File tarFile = new File(output, tarGz.getName().replace(".gz", ""));
        tarFile.delete();
        deCompressGZipFile(tarGz, tarFile);
        unTar(tarFile, output);
    }

    private static File deCompressGZipFile(File gZippedFile, File tarFile) throws IOException {
        try (GZIPInputStream gZIPInputStream = new GZIPInputStream(new FileInputStream(gZippedFile));
             FileOutputStream fos = new FileOutputStream(tarFile)) {
            byte[] buffer = new byte[1024];
            int len;
            while ((len = gZIPInputStream.read(buffer)) > 0) {
                fos.write(buffer, 0, len);
            }
        }
        return tarFile;
    }

    @SuppressFBWarnings({"RCN_REDUNDANT_NULLCHECK_WOULD_HAVE_BEEN_A_NPE", "RV_RETURN_VALUE_IGNORED_BAD_PRACTICE"})
    private static void unTar(final File inputFile, final File outputDir) throws Exception {
        try (final FileInputStream fis = new FileInputStream(inputFile);
                TarArchiveInputStream debInputStream = (TarArchiveInputStream)
                new ArchiveStreamFactory().createArchiveInputStream("tar", fis)) {
            TarArchiveEntry entry;
            while ((entry = (TarArchiveEntry) debInputStream.getNextEntry()) != null) {
                final File outputFile = new File(outputDir, entry.getName());
                if (!outputFile.toPath().normalize().startsWith(outputDir.toPath())) {
                    throw new IOException("Bad zip entry");
                }

                if (!outputFile.getParentFile().exists()) {
                    outputFile.getParentFile().mkdirs();
                }
                if (entry.isDirectory()) {
                    if (outputFile.exists()) {
                        FileUtils.deleteDirectory(outputFile);
                    }
                    if (!outputFile.mkdirs()) {
                        throw new IllegalStateException(
                                String.format("Couldn't create directory %s.", outputFile.getAbsolutePath()));
                    }
                } else {
                    try (final OutputStream outputFileStream = new FileOutputStream(outputFile)) {
                        IOUtils.copy(debInputStream, outputFileStream);
                    }
                }
            }
        }
    }


}
