package org.testcontainers.utility;

import com.google.common.base.Charsets;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.compress.archivers.tar.TarArchiveEntry;
import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream;
import org.apache.commons.compress.archivers.tar.TarConstants;
import org.apache.commons.lang.SystemUtils;
import org.jetbrains.annotations.NotNull;
import org.testcontainers.DockerClientFactory;
import org.testcontainers.UnstableAPI;
import org.testcontainers.images.builder.Transferable;

import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import java.io.UnsupportedEncodingException;
import java.net.URL;
import java.net.URLDecoder;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Enumeration;
import java.util.HashSet;
import java.util.Set;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;

import static lombok.AccessLevel.PACKAGE;
import static org.testcontainers.utility.PathUtils.recursiveDeleteDir;

/**
 * An abstraction over files and classpath resources aimed at encapsulating all the complexity of generating
 * a path that the Docker daemon is about to create a volume mount for.
 */
@RequiredArgsConstructor(access = PACKAGE)
@Slf4j
public class MountableFile implements Transferable {

    private static final String TESTCONTAINERS_TMP_DIR_PREFIX = ".testcontainers-tmp-";
    private static final String OS_MAC_TMP_DIR = "/tmp";
    private static final int BASE_FILE_MODE = 0100000;
    private static final int BASE_DIR_MODE = 0040000;

    private final String path;
    private final Integer forcedFileMode;

    @Getter(lazy = true)
    private final String resolvedPath = resolvePath();

    @Getter(lazy = true)
    private final String filesystemPath = resolveFilesystemPath();

    private String resourcePath;

    /**
     * Obtains a {@link MountableFile} corresponding to a resource on the classpath (including resources in JAR files)
     *
     * @param resourceName the classpath path to the resource
     * @return a {@link MountableFile} that may be used to obtain a mountable path
     */
    public static MountableFile forClasspathResource(@NotNull final String resourceName) {
        return forClasspathResource(resourceName, null);
    }

    /**
     * Obtains a {@link MountableFile} corresponding to a file on the docker host filesystem.
     *
     * @param path the path to the resource
     * @return a {@link MountableFile} that may be used to obtain a mountable path
     */
    public static MountableFile forHostPath(@NotNull final String path) {
        return forHostPath(path, null);
    }

    /**
     * Obtains a {@link MountableFile} corresponding to a file on the docker host filesystem.
     *
     * @param path the path to the resource
     * @return a {@link MountableFile} that may be used to obtain a mountable path
     */
    public static MountableFile forHostPath(final Path path) {
        return forHostPath(path, null);
    }

    /**
     * Obtains a {@link MountableFile} corresponding to a resource on the classpath (including resources in JAR files)
     *
     * @param resourceName the classpath path to the resource
     * @param mode octal value of posix file mode (000..777)
     * @return a {@link MountableFile} that may be used to obtain a mountable path
     */
    public static MountableFile forClasspathResource(@NotNull final String resourceName, Integer mode) {
        return new MountableFile(getClasspathResource(resourceName, new HashSet<>()).toString(), mode);
    }

    /**
     * Obtains a {@link MountableFile} corresponding to a file on the docker host filesystem.
     *
     * @param path the path to the resource
     * @param mode octal value of posix file mode (000..777)
     * @return a {@link MountableFile} that may be used to obtain a mountable path
     */
    public static MountableFile forHostPath(@NotNull final String path, Integer mode) {
        return new MountableFile(new File(path).toURI().toString(), mode);
    }

    /**
     * Obtains a {@link MountableFile} corresponding to a file on the docker host filesystem.
     *
     * @param path the path to the resource
     * @param mode octal value of posix file mode (000..777)
     * @return a {@link MountableFile} that may be used to obtain a mountable path
     */
    public static MountableFile forHostPath(final Path path, Integer mode) {
        return new MountableFile(path.toAbsolutePath().toString(), mode);
    }


    @NotNull
    private static URL getClasspathResource(@NotNull final String resourcePath, @NotNull final Set<ClassLoader> classLoaders) {

        final Set<ClassLoader> classLoadersToSearch = new HashSet<>(classLoaders);
        // try context and system classloaders as well
        classLoadersToSearch.add(Thread.currentThread().getContextClassLoader());
        classLoadersToSearch.add(ClassLoader.getSystemClassLoader());
        classLoadersToSearch.add(MountableFile.class.getClassLoader());

        for (final ClassLoader classLoader : classLoadersToSearch) {
            URL resource = classLoader.getResource(resourcePath);
            if (resource != null) {
                return resource;
            }

            // Be lenient if an absolute path was given
            if (resourcePath.startsWith("/")) {
                resource = classLoader.getResource(resourcePath.replaceFirst("/", ""));
                if (resource != null) {
                    return resource;
                }
            }
        }

        throw new IllegalArgumentException("Resource with path " + resourcePath + " could not be found on any of these classloaders: " + classLoadersToSearch);
    }

    private static String unencodeResourceURIToFilePath(@NotNull final String resource) {
        try {
            // Convert any url-encoded characters (e.g. spaces) back into unencoded form
            return URLDecoder.decode(resource.replaceAll("\\+", "%2B"), Charsets.UTF_8.name())
                    .replaceFirst("jar:", "")
                    .replaceFirst("file:", "")
                    .replaceAll("!.*", "");
        } catch (UnsupportedEncodingException e) {
            throw new IllegalStateException(e);
        }
    }

    /**
     * Obtain a path that the Docker daemon should be able to use to volume mount a file/resource
     * into a container. If this is a classpath resource residing in a JAR, it will be extracted to
     * a temporary location so that the Docker daemon is able to access it.
     *
     * @return a volume-mountable path.
     */
    private String resolvePath() {
        String result = getResourcePath();

        // Special case for Windows
        if (SystemUtils.IS_OS_WINDOWS && result.startsWith("/")) {
            // Remove leading /
            result = result.substring(1);
        }

        return result;
    }

    /**
     * Obtain a path in local filesystem that the Docker daemon should be able to use to volume mount a file/resource
     * into a container. If this is a classpath resource residing in a JAR, it will be extracted to
     * a temporary location so that the Docker daemon is able to access it.
     *
     * TODO: rename method accordingly and check if really needed like this
     *
     * @return
     */
    private String resolveFilesystemPath() {
        String result = getResourcePath();

        if (SystemUtils.IS_OS_WINDOWS && result.startsWith("/")) {
            result = PathUtils.createMinGWPath(result).substring(1);
        }

        return result;
    }

    private String getResourcePath() {
        if (path.contains(".jar!")) {
            resourcePath = extractClassPathResourceToTempLocation(this.path);
        } else {
            resourcePath = unencodeResourceURIToFilePath(path);
        }
        return resourcePath;
    }

    /**
     * Extract a file or directory tree from a JAR file to a temporary location.
     * This allows Docker to mount classpath resources as files.
     *
     * @param hostPath the path on the host, expected to be of the format 'file:/path/to/some.jar!/classpath/path/to/resource'
     * @return the path of the temporary file/directory
     */
    private String extractClassPathResourceToTempLocation(final String hostPath) {
        File tmpLocation = createTempDirectory();
        //noinspection ResultOfMethodCallIgnored
        tmpLocation.delete();

        String urldecodedJarPath = unencodeResourceURIToFilePath(hostPath);
        String internalPath = hostPath.replaceAll("[^!]*!/", "");

        try (JarFile jarFile = new JarFile(urldecodedJarPath)) {
            Enumeration<JarEntry> entries = jarFile.entries();

            while (entries.hasMoreElements()) {
                JarEntry entry = entries.nextElement();
                final String name = entry.getName();
                if (name.startsWith(internalPath)) {
                    log.debug("Copying classpath resource(s) from {} to {} to permit Docker to bind",
                            hostPath,
                            tmpLocation);
                    copyFromJarToLocation(jarFile, entry, internalPath, tmpLocation);
                }
            }

        } catch (IOException e) {
            throw new IllegalStateException("Failed to process JAR file when extracting classpath resource: " + hostPath, e);
        }

        // Mark temporary files/dirs for deletion at JVM shutdown
        deleteOnExit(tmpLocation.toPath());

        try {
            return tmpLocation.getCanonicalPath();
        } catch (IOException e) {
            throw new IllegalStateException(e);
        }
    }

    private File createTempDirectory() {
        try {
            if (SystemUtils.IS_OS_MAC) {
                return Files.createTempDirectory(Paths.get(OS_MAC_TMP_DIR), TESTCONTAINERS_TMP_DIR_PREFIX).toFile();
            }
            return Files.createTempDirectory(TESTCONTAINERS_TMP_DIR_PREFIX).toFile();
        } catch  (IOException e) {
            return new File(TESTCONTAINERS_TMP_DIR_PREFIX + Base58.randomString(5));
        }
    }

    @SuppressWarnings("ResultOfMethodCallIgnored")
    private void copyFromJarToLocation(final JarFile jarFile,
                                       final JarEntry entry,
                                       final String fromRoot,
                                       final File toRoot) throws IOException {

        String destinationName = entry.getName().replaceFirst(fromRoot, "");
        File newFile = new File(toRoot, destinationName);

        log.debug("Copying resource {} from JAR file {}",
                fromRoot,
                jarFile.getName());

        if (!entry.isDirectory()) {
            // Create parent directories
            Path parent = newFile.getAbsoluteFile().toPath().getParent();
            parent.toFile().mkdirs();
            newFile.deleteOnExit();

            try (InputStream is = jarFile.getInputStream(entry)) {
                Files.copy(is, newFile.toPath());
            } catch (IOException e) {
                log.error("Failed to extract classpath resource " + entry.getName() + " from JAR file " + jarFile.getName(), e);
                throw e;
            }
        }
    }

    private void deleteOnExit(final Path path) {
        Runtime.getRuntime().addShutdownHook(new Thread(DockerClientFactory.TESTCONTAINERS_THREAD_GROUP, () -> recursiveDeleteDir(path)));
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public void transferTo(final TarArchiveOutputStream outputStream, String destinationPathInTar) {
        recursiveTar(destinationPathInTar, this.getResolvedPath(), this.getResolvedPath(), outputStream);
    }

    /*
     * Recursively copies a file/directory into a TarArchiveOutputStream
     */
    private void recursiveTar(String entryFilename, String rootPath, String itemPath, TarArchiveOutputStream tarArchive) {
        try {
            final File sourceFile = new File(itemPath).getCanonicalFile();     // e.g. /foo/bar/baz
            final File sourceRootFile = new File(rootPath).getCanonicalFile();     // e.g. /foo
            final String relativePathToSourceFile = sourceRootFile.toPath().relativize(sourceFile.toPath()).toFile().toString();    // e.g. /bar/baz

            final String tarEntryFilename;
            if (relativePathToSourceFile.isEmpty()) {
                tarEntryFilename = entryFilename; // entry filename e.g. xyz => xyz
            } else {
                tarEntryFilename = entryFilename + "/" + relativePathToSourceFile; // entry filename e.g. /xyz/bar/baz => /foo/bar/baz
            }

            final TarArchiveEntry tarEntry = new TarArchiveEntry(sourceFile, tarEntryFilename.replaceAll("^/", ""));

            // TarArchiveEntry automatically sets the mode for file/directory, but we can update to ensure that the mode is set exactly (inc executable bits)
            tarEntry.setMode(getUnixFileMode(itemPath));
            tarArchive.putArchiveEntry(tarEntry);

            if (sourceFile.isFile()) {
                Files.copy(sourceFile.toPath(), tarArchive);
            }
            // a directory entry merely needs to exist in the TAR file - there is no data stored yet
            tarArchive.closeArchiveEntry();

            final File[] children = sourceFile.listFiles();
            if (children != null) {
                // recurse into child files/directories
                for (final File child : children) {
                    recursiveTar(entryFilename, sourceRootFile.getCanonicalPath(), child.getCanonicalPath(), tarArchive);
                }
            }
        } catch (IOException e) {
            log.error("Error when copying TAR file entry: {}", itemPath, e);
            throw new UncheckedIOException(e); // fail fast
        }
    }

    @Override
    public long getSize() {

        final File file = new File(this.getResolvedPath());
        if (file.isFile()) {
            return file.length();
        } else {
            return 0;
        }
    }

    @Override
    public String getDescription() {
        return this.getResolvedPath();
    }

    @Override
    public int getFileMode() {
        return getUnixFileMode(this.getResolvedPath());
    }

    private int getUnixFileMode(final String pathAsString) {
        final Path path = Paths.get(pathAsString);
        if (this.forcedFileMode != null) {
            return this.getModeValue(path);
        }
        return getUnixFileMode(path);
    }

    @UnstableAPI
    public static int getUnixFileMode(final Path path) {
        try {
            int unixMode = (int) Files.readAttributes(path, "unix:mode").get("mode");
            // Truncate mode bits for z/OS
            if ("OS/390".equals(SystemUtils.OS_NAME) ||
                "z/OS".equals(SystemUtils.OS_NAME) ||
                "zOS".equals(SystemUtils.OS_NAME) ) {
                unixMode &= TarConstants.MAXID;
                unixMode |= Files.isDirectory(path) ? 040000 : 0100000;
            }
            return unixMode;
        } catch (IOException | UnsupportedOperationException e) {
            // fallback for non-posix environments
            int mode = DEFAULT_FILE_MODE;
            if (Files.isDirectory(path)) {
                mode = DEFAULT_DIR_MODE;
            } else if (Files.isExecutable(path)) {
                mode |= 0111; // equiv to +x for user/group/others
            }

            return mode;
        }
    }

    private int getModeValue(final Path path) {
        int result = Files.isDirectory(path) ? BASE_DIR_MODE : BASE_FILE_MODE;
        return result | this.forcedFileMode;
    }
}