/*
 * 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.openejb.util;

import org.apache.openejb.OpenEJBRuntimeException;
import org.apache.openejb.loader.FileUtils;
import org.apache.openejb.loader.Files;
import org.apache.openejb.loader.IO;
import org.apache.openejb.loader.SystemInstance;

import java.io.Closeable;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.StringTokenizer;
import java.util.TreeMap;
import java.util.jar.Attributes;
import java.util.jar.JarFile;
import java.util.jar.Manifest;

public class UrlCache {

    private static final Logger logger = Logger.getInstance(LogCategory.OPENEJB, UrlCache.class);
    public static final boolean antiJarLocking;
    public static final File cacheDir;

    static {
        antiJarLocking = SystemInstance.get().getOptions().get("antiJarLocking", false);

        if (antiJarLocking) {
            cacheDir = createCacheDir();
            logger.info("AntiJarLocking enabled. Using URL cache dir " + cacheDir);
        } else {
            cacheDir = null;
        }
    }


    private final Map<String, Map<URL, File>> cache = new TreeMap<>();

    public synchronized URL[] cacheUrls(final String appId, final URL[] urls) {
        if (!antiJarLocking) {
            return urls;
        }

        // the final cached urls
        final LinkedHashSet<URL> cachedUrls = new LinkedHashSet<>();

        // this stack contains the urls to be processed... when manifest class path entries
        // are added they are added to the top (front) of the stack so manifest order is maintained
        final LinkedList<URL> locationStack = new LinkedList<>(Arrays.asList(urls));
        while (!locationStack.isEmpty()) {
            final URL url = locationStack.removeFirst();

            // Skip any duplicate urls in the claspath
            if (cachedUrls.contains(url)) {
                continue;
            }

            // cache the URL
            final File file = cacheUrl(appId, url);

            // if the url was successfully cached, process it's manifest classpath
            if (file != null) {
                try {
                    cachedUrls.add(file.toURI().toURL());

                    // push the manifest classpath on the stack (make sure to maintain the order)
                    final List<URL> manifestClassPath = getManifestClassPath(url, file);
                    locationStack.addAll(0, manifestClassPath);
                } catch (final MalformedURLException e) {
                    // invalid cache file - this should never happen
                    logger.error("Error caching url. Original jar file will be used which may result in a file lock: url=" + url, e);
                    cachedUrls.add(url);
                }
            } else {
                // URL was not cached - simply pass through the url
                cachedUrls.add(url);
            }
        }

        return cachedUrls.toArray(new URL[cachedUrls.size()]);
    }

    public synchronized void releaseUrls(final String appId) {
        logger.debug("Releasing URLs for application " + appId);

        final Map<URL, File> urlFileMap = cache.remove(appId);
        if (urlFileMap != null) {
            for (final File file : urlFileMap.values()) {
                if (file.delete()) {
                    logger.debug("Deleted cached file " + file);
                } else {
                    logger.debug("Unable to delete cached file " + file);
                }
            }
        }
    }

    public File getUrlCachedName(final String appId, final URL url) {
        final Map<URL, File> appCache = getAppCache(appId);
        if (appCache.containsKey(url)) {
            return appCache.get(url);
        }
        return null;
    }

    public boolean isUrlCached(final String appId, final URL url) {
        final Map<URL, File> appCache = getAppCache(appId);
        return appCache.containsKey(url);
    }

    public URL getUrlKeyCached(final String appId, final File file) {
        if (file == null) {
            return null;
        }
        final Map<URL, File> appCache = getAppCache(appId);
        for (final Map.Entry<URL, File> entry : appCache.entrySet()) {
            if (entry.getValue().equals(file)) {
                return entry.getKey();
            }
        }

        final URL keyUrl;
        try {
            keyUrl = file.toURI().toURL();
        } catch (final MalformedURLException e) {
            return null;
        }
        if (appCache.containsKey(keyUrl)) {
            return keyUrl;
        }
        return null;
    }

    private synchronized File cacheUrl(final String appId, URL url) {
        File sourceFile;
        if (!"file".equals(url.getProtocol())) {
            // todo: download the jar ourselves?
            // for now return null which means we did not cache
            return null;
        } else {
            // verify file
            sourceFile = URLs.toFile(url);
            if (!sourceFile.exists()) {
                return null;
            }
            if (!sourceFile.canRead()) {
                return null;
            }

            // if file is a directory, there is no need to cache
            if (sourceFile.isDirectory()) {
                return sourceFile;
            }

            // Create absolute file URL
            sourceFile = sourceFile.getAbsoluteFile();
            try {
                url = sourceFile.toURI().toURL();
            } catch (final MalformedURLException ignored) {
                // no-op
            }
        }

        // check if file is already cached
        final Map<URL, File> appCache = getAppCache(appId);
        if (appCache.containsKey(url)) {
            return appCache.get(url);
        }

        // if the file is already in the cache, don't recopy it to the cache dir
        if (sourceFile.getParentFile().equals(cacheDir)) {
            // mark it as part of the application, so it cleaned up when the application is undeployed
            appCache.put(url, sourceFile);
            return sourceFile;
        }

        // generate a nice cache file name
        final String name = sourceFile.getName();
        final int dot = name.lastIndexOf('.');
        String prefix = name;
        String suffix = "";
        if (dot > 0) {
            prefix = name.substring(0, dot) + "-";
            suffix = name.substring(dot, name.length());
        }

        // copy the file to the cache dir to avoid file locks
        File cacheFile = null;
        boolean success;
        try {
            try {
                cacheFile = File.createTempFile(prefix, suffix, cacheDir);
            } catch (final Throwable e) {
                final File tmp = new File("tmp");
                if (!tmp.exists() && !tmp.mkdirs()) {
                    throw new IOException("Failed to create local tmp directory: " + tmp.getAbsolutePath());
                }

                cacheFile = File.createTempFile(prefix, suffix, tmp);
            }
            cacheFile.deleteOnExit();
            success = JarExtractor.copyRecursively(sourceFile, cacheFile);
        } catch (final IOException e) {
            success = false;
        }

        if (success) {
            // add cache file to cache
            appCache.put(url, cacheFile);
            logger.debug("Coppied jar file to " + cacheFile);
            return cacheFile;
        } else {
            // clean up failed copy
            JarExtractor.delete(cacheFile);
            logger.error("Unable to copy jar into URL cache directory. Original jar file will be used which may result in a file lock: file=" + sourceFile);
            return null;
        }
    }

    private synchronized Map<URL, File> getAppCache(final String appId) {
        Map<URL, File> urlFileMap = cache.computeIfAbsent(appId, k -> new LinkedHashMap<>());
        return urlFileMap;
    }

    private List<URL> getManifestClassPath(final URL codeSource, final File location) {
        try {
            // get the manifest, if possible
            final Manifest manifest = loadManifest(location);
            if (manifest == null) {
                // some locations don't have a manifest
                return Collections.emptyList();
            }

            // get the class-path attribute, if possible
            final String manifestClassPath = manifest.getMainAttributes().getValue(Attributes.Name.CLASS_PATH);
            if (manifestClassPath == null) {
                return Collections.emptyList();
            }

            // build the urls...
            // the class-path attribute is space delimited
            final LinkedList<URL> classPathUrls = new LinkedList<>();
            for (final StringTokenizer tokenizer = new StringTokenizer(manifestClassPath, " "); tokenizer.hasMoreTokens(); ) {
                final String entry = tokenizer.nextToken();
                try {
                    // the class path entry is relative to the resource location code source
                    final URL entryUrl = new URL(codeSource, entry);
                    classPathUrls.addLast(entryUrl);
                } catch (final MalformedURLException ignored) {
                    // most likely a poorly named entry
                }
            }
            return classPathUrls;
        } catch (final IOException ignored) {
            // error opening the manifest
            return Collections.emptyList();
        }
    }

    private Manifest loadManifest(final File location) throws IOException {
        if (location.isDirectory()) {
            final File manifestFile = new File(location, "META-INF/MANIFEST.MF");

            if (manifestFile.isFile() && manifestFile.canRead()) {
                InputStream in = null;
                try {
                    in = IO.read(manifestFile);
                    return new Manifest(in);
                } finally {
                    close(in);
                }
            }
        } else {
            final JarFile jarFile = new JarFile(location);
            try {
                return jarFile.getManifest();
            } finally {
                close(jarFile);
            }
        }
        return null;
    }

    private static File createCacheDir() {
        try {
            final FileUtils openejbBase = SystemInstance.get().getBase();

            File dir = null;
            // if we are not embedded, cache (temp) dir is under base dir
            if (SystemInstance.get().getConf(null).exists()) {
                try {
                    dir = openejbBase.getDirectory("temp");
                } catch (final IOException e) {
                    //Ignore
                }
            }

            // if we are embedded, tmp dir is in the system tmp dir
            if (dir == null) {
                dir = Files.tmpdir();
            }

            // If the cache dir already exists then empty its contents
            if (dir.exists()) {
                final File[] files = dir.listFiles();
                if (null != files) {
                    for (final File f : files) {
                        deleteDir(f);
                    }
                }
            } else {
                dir = createCacheDir(new File(dir.getAbsolutePath()));
            }

            return dir;

        } catch (final IOException e) {
            throw new OpenEJBRuntimeException(e);
        }
    }

    private static File createCacheDir(final File dir) throws IOException {

        if (dir.exists() && dir.isDirectory()) {
            return dir;
        }

        if (dir.exists() && !dir.isDirectory()) {
            throw new IOException("Cache temp directory held by file: " + dir);
        }

        if (!dir.mkdirs()) {
            throw new IOException("Unable to create cache temp directory: " + dir);
        }

        Thread.yield();

        return dir;
    }

    /**
     * Delete the specified directory, including all of its contents and
     * subdirectories recursively.
     *
     * @param dir File object representing the directory to be deleted
     */
    public static void deleteDir(final File dir) {
        if (dir == null) {
            return;
        }

        final File[] fileNames = dir.listFiles();
        if (fileNames != null) {
            for (final File file : fileNames) {
                if (file.isDirectory()) {
                    deleteDir(file);
                } else {
                    if (file.delete()) {
                        logger.debug("Deleted file " + file);
                    } else {
                        logger.debug("Unable to delete file " + file);
                    }

                }
            }
        }
        if (dir.delete()) {
            logger.debug("Deleted file " + dir);
        } else {
            logger.debug("Unable to delete file " + dir);
        }
    }

    private static void close(final Closeable closeable) {
        if (closeable != null) {
            try {
                closeable.close();
            } catch (final IOException ignored) {
                // no-op
            }
        }
    }

    private static void close(final JarFile closeable) {
        if (closeable != null) {
            try {
                closeable.close();
            } catch (final IOException ignored) {
                // no-op
            }
        }
    }
}
