/*
 * 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.jackrabbit.core.data;

import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.atomic.AtomicLong;

import org.apache.commons.io.FileUtils;
import org.apache.commons.io.IOUtils;
import org.apache.jackrabbit.util.TransientFileFactory;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * This class implements a LRU cache used by {@link CachingDataStore}. If cache
 * size exceeds limit, this cache goes in purge mode. In purge mode any
 * operation to cache is no-op. After purge cache size would be less than
 * cachePurgeResizeFactor * maximum size.
 */
public class LocalCache {

    /**
     * Logger instance.
     */
    static final Logger LOG = LoggerFactory.getLogger(LocalCache.class);

    /**
     * The file names of the files that need to be deleted.
     */
    final Set<String> toBeDeleted = new HashSet<String>();

    /**
     * The filename Vs file size LRU cache.
     */
    LRUCache cache;

    /**
     * The directory where the files are created.
     */
    private final File directory;

    /**
     * The directory where tmp files are created.
     */
    private final File tmp;

    /**
     * If true cache is in purgeMode and not available. All operation would be
     * no-op.
     */
    private volatile boolean purgeMode;
    
    private AsyncUploadCache asyncUploadCache;
    
    private AtomicLong cacheMissCounter = new AtomicLong();
    
    private AtomicLong cacheMissDuration = new AtomicLong();
    

    /**
     * Build LRU cache of files located at 'path'. It uses lastModified property
     * of file to build LRU cache. If cache size exceeds limit size, this cache
     * goes in purge mode. In purge mode any operation to cache is no-op.
     * 
     * @param path file system path
     * @param tmpPath temporary directory used by cache.
     * @param maxSizeInBytes maximum size of cache.
     * @param cachePurgeTrigFactor factor which triggers cache to purge mode.
     * That is if current size exceed (cachePurgeTrigFactor * maxSizeInBytes), the
     * cache will go in auto-purge mode.
     * @param cachePurgeResizeFactor after cache purge size of cache will be
     * just less (cachePurgeResizeFactor * maxSizeInBytes).
     * @param asyncUploadCache {@link AsyncUploadCache}
     */
    public LocalCache(String path, String tmpPath, long maxSizeInBytes, double cachePurgeTrigFactor,
            double cachePurgeResizeFactor, AsyncUploadCache asyncUploadCache) {
        directory = new File(path);
        tmp = new File(tmpPath);
        LOG.info(
            "cachePurgeTrigFactor =[{}], cachePurgeResizeFactor =[{}],  " +
            "cachePurgeTrigFactorSize =[{}], cachePurgeResizeFactorSize =[{}]",
            new Object[] { cachePurgeTrigFactor, cachePurgeResizeFactor,
                (cachePurgeTrigFactor * maxSizeInBytes), 
                (cachePurgeResizeFactor * maxSizeInBytes) });
        cache = new LRUCache(maxSizeInBytes, cachePurgeTrigFactor, cachePurgeResizeFactor);
        this.asyncUploadCache = asyncUploadCache;
        new Thread(new CacheBuildJob()).start();
    }

    /**
     * Store an item in the cache and return the input stream. If cache is in
     * purgeMode or file doesn't exists, inputstream from a
     * {@link TransientFileFactory#createTransientFile(String, String, File)} is
     * returned. Otherwise inputStream from cached file is returned. This method
     * doesn't close the incoming inputstream.
     * 
     * @param fileName the key of cache.
     * @param in {@link InputStream}
     * @return the (new) input stream.
     */
    public InputStream store(String fileName, final InputStream in)
            throws IOException {
        fileName = fileName.replace("\\", "/");
        File f = getFile(fileName);
        long length = 0;
        if (!f.exists() || isInPurgeMode()) {
            OutputStream out = null;
            File transFile = null;
            try {
                TransientFileFactory tff = TransientFileFactory.getInstance();
                transFile = tff.createTransientFile("s3-", "tmp", tmp);
                out = new BufferedOutputStream(new FileOutputStream(transFile));
                length = IOUtils.copyLarge(in, out);
            } finally {
                IOUtils.closeQuietly(out);
            }
            // rename the file to local fs cache
            if (canAdmitFile(length)
                && (f.getParentFile().exists() || f.getParentFile().mkdirs())
                && transFile.renameTo(f) && f.exists()) {
                if (transFile.exists() && transFile.delete()) {
                    LOG.info("tmp file [{}] not deleted successfully",
                        transFile.getAbsolutePath());
                }
                transFile = null;
                LOG.debug(
                    "file [{}] doesn't exists. adding to local cache using inputstream.",
                    fileName);
                cache.put(fileName, f.length());
            } else {
                LOG.debug(
                    "file [{}] doesn't exists. returning transient file [{}].",
                    fileName, transFile.getAbsolutePath());
                f = transFile;
            }
        } else {
            f.setLastModified(System.currentTimeMillis());
            LOG.debug(
                "file [{}]  exists. adding to local cache using inputstream.",
                fileName);
            cache.put(fileName, f.length());
        }
        tryPurge();
        return new LazyFileInputStream(f);
    }

    /**
     * Store an item along with file in cache. Cache size is increased by
     * {@link File#length()} If file already exists in cache,
     * {@link File#setLastModified(long)} is updated with current time.
     * 
     * @param fileName the key of cache.
     * @param src file to be added to cache.
     */
    public File store(String fileName, final File src) {
        try {
            return store(fileName, src, false).getFile();
        } catch (IOException ioe) {
            LOG.warn("Exception in addding file [" + fileName + "] to local cache.", ioe);
        }
        return null;
    }

    /**
     * This method add file to {@link LocalCache} and tries that file can be
     * added to {@link AsyncUploadCache}. If file is added to
     * {@link AsyncUploadCache} successfully, it sets
     * {@link AsyncUploadCacheResult#setAsyncUpload(boolean)} to true.
     *
     * @param fileName name of the file.
     * @param src source file.
     * @param tryForAsyncUpload If true it tries to add fileName to
     *            {@link AsyncUploadCache}
     * @return {@link AsyncUploadCacheResult}. This method sets
     *         {@link AsyncUploadCacheResult#setAsyncUpload(boolean)} to true, if
     *         fileName is added to {@link AsyncUploadCache} successfully else
     *         it sets {@link AsyncUploadCacheResult#setAsyncUpload(boolean)} to
     *         false. {@link AsyncUploadCacheResult#getFile()} contains cached
     *         file, if it is added to {@link LocalCache} or original file.
     * @throws IOException
     */
    public AsyncUploadCacheResult store(String fileName, File src,
            boolean tryForAsyncUpload) throws IOException {
        fileName = fileName.replace("\\", "/");
        File dest = getFile(fileName);
        File parent = dest.getParentFile();
        AsyncUploadCacheResult result = new AsyncUploadCacheResult();
        result.setFile(src);
        result.setAsyncUpload(false);
        boolean destExists = false;
        if ((destExists = dest.exists())
            || (src.exists() && !dest.exists() && !src.equals(dest)
                && canAdmitFile(src.length())
                && (parent.exists() || parent.mkdirs()) && (src.renameTo(dest)))) {
            if (destExists) {
                dest.setLastModified(System.currentTimeMillis());
            }
            LOG.debug("file [{}] moved to [{}] ", src.getAbsolutePath(), dest.getAbsolutePath());
            LOG.debug(
                "file [{}]  exists= [{}] added to local cache, isLastModified [{}]",
                new Object[] { dest.getAbsolutePath(), dest.exists(),
                    destExists });
            
            cache.put(fileName, dest.length());
            result.setFile(dest);
            if (tryForAsyncUpload) {
                result.setAsyncUpload(asyncUploadCache.add(fileName).canAsyncUpload());
            }
        } else {
            LOG.info("file [{}] exists= [{}] not added to local cache.",
                fileName, destExists);
        }
        tryPurge();
        return result;
    }
    /**
     * Return the inputstream from from cache, or null if not in the cache.
     * 
     * @param fileName name of file.
     * @return  stream or null.
     */
    public InputStream getIfStored(String fileName) throws IOException {
        File file = getFileIfStored(fileName);
        return file == null ? null : new LazyFileInputStream(file);
    }

    public File getFileIfStored(String fileName) throws IOException {
        fileName = fileName.replace("\\", "/");
        File f = getFile(fileName);
        long diff = (System.currentTimeMillis() - cacheMissDuration.get()) / 1000;
        // logged at 5 minute interval minimum
        if (diff > 5 * 60) {
            LOG.info("local cache misses [{}] in [{}] sec", new Object[] {
                cacheMissCounter.getAndSet(0), diff });
            cacheMissDuration.set(System.currentTimeMillis());
        }
        
        // return file in purge mode = true and file present in asyncUploadCache
        // as asyncUploadCache's files will be not be deleted in cache purge.
        if (!f.exists() || (isInPurgeMode() && !asyncUploadCache.hasEntry(fileName, false))) {
            LOG.debug(
                "getFileIfStored returned: purgeMode=[{}], file=[{}] exists=[{}]",
                new Object[] { isInPurgeMode(), f.getAbsolutePath(), f.exists() });
            cacheMissCounter.incrementAndGet();
            return null;
        } else {
            // touch entry in LRU caches
            f.setLastModified(System.currentTimeMillis());
            cache.get(fileName);
            return f;
        }
    }

    /**
     * Delete file from cache. Size of cache is reduced by file length. The
     * method is no-op if file doesn't exist in cache.
     * 
     * @param fileName file name that need to be removed from cache.
     */
    public void delete(String fileName) {
        if (isInPurgeMode()) {
            LOG.debug("purgeMode true :delete returned");
            return;
        }
        fileName = fileName.replace("\\", "/");
        cache.remove(fileName);
    }

    /**
     * Returns length of file if exists in cache else returns null.
     * @param fileName name of the file.
     */
    public Long getFileLength(String fileName) {
        Long length = null;
        try {
            length = cache.get(fileName);
            if( length == null ) {
                File f = getFileIfStored(fileName);
                if (f != null) {
                    length = f.length();
                }
            }
        } catch (IOException ignore) {

        }
        return length;
    }

    /**
     * Close the cache. Cache maintain set of files which it was not able to
     * delete successfully. This method will an attempt to delete all
     * unsuccessful delete files.
     */
    public void close() {
        LOG.debug("close");
        deleteOldFiles();
    }

    /**
     * Check if cache can admit file of given length.
     * @param length of the file.
     * @return true if yes else return false.
     */
    private boolean canAdmitFile(final long length) {
      //order is important here
        boolean value = !isInPurgeMode() && (cache.canAdmitFile(length));
        if (!value) {
            LOG.debug("cannot admit file of length=[{}] and currentSizeInBytes=[{}] ",
                length, cache.currentSizeInBytes);
        }
        return value;
    }

    /**
     * Return true if cache is in purge mode else return false.
     */
    synchronized boolean isInPurgeMode() {
        return purgeMode;
    }

    /**
     * Set purge mode. If set to true all cache operation will be no-op. If set
     * to false, all operations to cache are available.
     * 
     * @param purgeMode purge mode
     */
    synchronized void setPurgeMode(final boolean purgeMode) {
        this.purgeMode = purgeMode;
    }

    File getFile(final String fileName) {
        return new File(directory, fileName);
    }

    private void deleteOldFiles() {
        int initialSize = toBeDeleted.size();
        int count = 0;
        for (String fileName : new ArrayList<String>(toBeDeleted)) {
            fileName = fileName.replace("\\", "/");
            if( cache.remove(fileName) != null) {
                count++;
            }
        }
        LOG.info("deleted [{}]/[{}] files.", count, initialSize);
    }

    /**
     * This method tries to delete a file. If it is not able to delete file due
     * to any reason, it add it toBeDeleted list.
     * 
     * @param fileName name of the file which will be deleted.
     * @return true if this method deletes file successfuly else return false.
     */
    boolean tryDelete(final String fileName) {
        LOG.debug("try deleting file [{}]", fileName);
        File f = getFile(fileName);
        if (f.exists() && f.delete()) {
            LOG.info("File [{}]  deleted successfully", f.getAbsolutePath());
            toBeDeleted.remove(fileName);
            while (true) {
                f = f.getParentFile();
                if (f.equals(directory) || f.list().length > 0) {
                    break;
                }
                // delete empty parent folders (except the main directory)
                f.delete();
            }
            return true;
        } else if (f.exists()) {
            LOG.info("not able to delete file [{}]", f.getAbsolutePath());
            toBeDeleted.add(fileName);
            return false;
        }
        return true;
    }

    static int maxSizeElements(final long bytes) {
        // after a CQ installation, the average item in
        // the data store is about 52 KB
        int count = (int) (bytes / 65535);
        count = Math.max(1024, count);
        count = Math.min(64 * 1024, count);
        return count;
    }
    
    /**
     * This method tries purging of local cache. It checks if local cache
     * has exceeded the defined limit then it triggers purge cache job in a
     * seperate thread.
     */
    synchronized void tryPurge() {
        if (!isInPurgeMode()
            && cache.currentSizeInBytes > cache.cachePurgeTrigSize) {
            setPurgeMode(true);
            LOG.info(
                "cache.entries = [{}], currentSizeInBytes=[{}]  exceeds cachePurgeTrigSize=[{}]",
                new Object[] { cache.size(), cache.currentSizeInBytes,
                    cache.cachePurgeTrigSize });
            new Thread(new PurgeJob()).start();
        } else {
            LOG.debug(
                "currentSizeInBytes=[{}],cachePurgeTrigSize=[{}], isInPurgeMode =[{}]",
                new Object[] { cache.currentSizeInBytes,
                    cache.cachePurgeTrigSize, isInPurgeMode() });
        }
    }

    /**
     * A LRU based extension {@link LinkedHashMap}. The key is file name and
     * value is length of file.
     */
    private class LRUCache extends LinkedHashMap<String, Long> {
        private static final long serialVersionUID = 1L;

        volatile long currentSizeInBytes;

        final long maxSizeInBytes;

        final long cachePurgeResize;
        
        final long cachePurgeTrigSize;

        LRUCache(final long maxSizeInBytes,
                final double cachePurgeTrigFactor,
                final double cachePurgeResizeFactor) {
            super(maxSizeElements(maxSizeInBytes), (float) 0.75, true);
            this.maxSizeInBytes = maxSizeInBytes;
            this.cachePurgeTrigSize = new Double(cachePurgeTrigFactor
                * maxSizeInBytes).longValue();
            this.cachePurgeResize = new Double(cachePurgeResizeFactor
                * maxSizeInBytes).longValue();
        }

        /**
         * Overridden {@link Map#remove(Object)} to delete corresponding file
         * from file system.
         */
        @Override
        public synchronized Long remove(final Object key) {
            String fileName = (String) key;
            fileName = fileName.replace("\\", "/");
            try {
                // not removing file from local cache, if there is in progress
                // async upload on it.
                if (asyncUploadCache.hasEntry(fileName, false)) {
                    LOG.info(
                        "AsyncUploadCache upload contains file [{}]. Not removing it from LocalCache.",
                        fileName);
                    return null;
                }
            } catch (IOException e) {
                LOG.debug("error: ", e);
                return null;
            }
            Long flength = null;
            if (tryDelete(fileName)) {
                flength = super.remove(key);
                if (flength != null) {
                    LOG.debug("cache entry [{}], with size [{}] removed.",
                        fileName, flength);
                    currentSizeInBytes -= flength.longValue();
                }
            } else if (!getFile(fileName).exists()) {
                // second attempt. remove from cache if file doesn't exists
                flength = super.remove(key);
                if (flength != null) {
                    LOG.debug(
                        "file not exists. cache entry [{}], with size [{}] removed.",
                        fileName, flength);
                    currentSizeInBytes -= flength.longValue();
                }
            } else {
                LOG.info("not able to remove cache entry [{}], size [{}]", key,
                    super.get(key));
            }
            return flength;
        }

        @Override
        public Long put(final String fileName, final Long value) {
            if( isInPurgeMode()) {
                LOG.debug("cache is purge mode: put is no-op");
                return null;
            }
            synchronized (this) {
                Long oldValue = cache.get(fileName);
                if (oldValue == null) {
                    long flength = value.longValue();
                    currentSizeInBytes += flength;
                    return super.put(fileName.replace("\\", "/"), value);
                }
                toBeDeleted.remove(fileName);
                return oldValue;
            }
        }
        
        @Override
        public Long get(Object key) {
            if( isInPurgeMode()) {
                LOG.debug("cache is purge mode: get is no-op");
                return null;
            }
            synchronized (this) {
                return super.get(key);
            }
        }
        
        /**
         * This method check if cache can admit file of given length. 
         * @param length length of file.
         * @return true if cache size + length is less than maxSize.
         */
        synchronized boolean canAdmitFile(final long length) {
            return cache.currentSizeInBytes + length < cache.maxSizeInBytes;
        }
    }

    /**
     * This class performs purging of local cache. It implements
     * {@link Runnable} and should be invoked in a separate thread.
     */
    private class PurgeJob implements Runnable {
        public PurgeJob() {
            // TODO Auto-generated constructor stub
        }

        /**
         * This method purges local cache till its size is less than
         * cacheResizefactor * maxSize
         */
        @Override
        public void run() {
            try {
                synchronized (cache) {
                    // first try to delete toBeDeleted files
                    int initialSize = cache.size();
                    LOG.info(" cache purge job started. initial cache entries = [{}]", initialSize);
                    for (String fileName : new ArrayList<String>(toBeDeleted)) {
                        cache.remove(fileName);
                    }
                    int skipCount = 0;
                    Iterator<Map.Entry<String, Long>> itr = cache.entrySet().iterator();
                    while (itr.hasNext()) {
                        Map.Entry<String, Long> entry = itr.next();
                        if (entry.getKey() != null) {
                            if (cache.currentSizeInBytes > cache.cachePurgeResize) {
                                if (cache.remove(entry.getKey()) != null) {
                                    itr = cache.entrySet().iterator();
                                    for (int i = 0; i < skipCount && itr.hasNext(); i++) {
                                        itr.next();
                                    }
                                } else {
                                    skipCount++;
                                }
                            } else {
                                break;
                            }
                        }
                    }
                    LOG.info(
                        " cache purge job completed: cleaned [{}] files and currentSizeInBytes = [{}]",
                        (initialSize - cache.size()), cache.currentSizeInBytes);
                }
            } catch (Exception e) {
                LOG.error("error in purge jobs:", e);
            } finally {
                setPurgeMode(false);
            }
        }
    }
    
    /**
     * This class implements {@link Runnable} interface to build LRU cache
     * asynchronously.
     */
    private class CacheBuildJob implements Runnable {

        
        public void run() {
            long startTime = System.currentTimeMillis();
            ArrayList<File> allFiles = new ArrayList<File>();
            Iterator<File> it = FileUtils.iterateFiles(directory, null, true);
            while (it.hasNext()) {
                File f = it.next();
                allFiles.add(f);
            }
            long t1 = System.currentTimeMillis();
            LOG.debug("Time taken to recursive [{}] took [{}] sec",
                allFiles.size(), ((t1 - startTime) / 1000));

            String dataStorePath = directory.getAbsolutePath();
            // convert to java path format
            dataStorePath = dataStorePath.replace("\\", "/");
            LOG.info("directoryPath = " + dataStorePath);

            String tmpPath = tmp.getAbsolutePath();
            tmpPath = tmpPath.replace("\\", "/");
            LOG.debug("tmp path [{}]", tmpPath); 
            long time = System.currentTimeMillis();
            int count = 0;
            for (File f : allFiles) {
                if (f.exists()) {
                    count++;
                    String name = f.getPath();
                    String filePath = f.getAbsolutePath();
                    // convert to java path format
                    name = name.replace("\\", "/");
                    filePath = filePath.replace("\\", "/");
                    // skipped any temp file
                    if(filePath.startsWith(tmpPath) ) {
                        LOG.info    ("tmp file [{}] skipped ", filePath);
                        continue;
                    }
                    if (filePath.startsWith(dataStorePath)) {
                        name = filePath.substring(dataStorePath.length());
                    }
                    if (name.startsWith("/") || name.startsWith("\\")) {
                        name = name.substring(1);
                    }
                    store(name, f);
                    long now = System.currentTimeMillis();
                    if (now > time + 10000) {
                        LOG.info("Processed {" + (count) + "}/{" + allFiles.size() + "}");
                        time = now;
                    }
                }
            }
            LOG.debug(
                "Processed [{}]/[{}], currentSizeInBytes = [{}], maxSizeInBytes = [{}], cache.filecount = [{}]",
                new Object[] { count, allFiles.size(),
                    cache.currentSizeInBytes, cache.maxSizeInBytes,
                    cache.size() });
            long t3 = System.currentTimeMillis();
            LOG.info("Time to build cache of  [{}] files took [{}] sec",
                allFiles.size(), ((t3 - startTime) / 1000));
        }
    }
}

