/*
 * 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.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.ObjectInput;
import java.io.ObjectInputStream;
import java.io.ObjectOutput;
import java.io.ObjectOutputStream;
import java.io.OutputStream;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * This class holds all in progress uploads. This class contains two data
 * structures, one is {@link #asyncUploadMap} which is {@link Map<String, Long>}
 * of file path vs lastModified of upload. The second {@link #toBeDeleted} is
 * {@link Set<String>} of upload which is marked for delete, while it is already
 * in progress. Before starting an asynchronous upload, it requires to invoke
 * {@link #add(String)} to add entry to {@link #asyncUploadMap}. After
 * asynchronous upload completes, it requires to invoke
 * {@link #remove(String, AsyncUploadCacheResult)} to remove entry from
 * {@link #asyncUploadMap} Any modification to this class are immediately
 * persisted to local file system. {@link #asyncUploadMap} is persisted to /
 * {@link homeDir}/ {@link #PENDIND_UPLOAD_FILE}. {@link #toBeDeleted} is
 * persisted to / {@link homeDir}/ {@link #TO_BE_DELETED_UPLOAD_FILE}. The /
 * {@link homeDir} refer to ${rep.home}.
 */
public class AsyncUploadCache {
    private static final Logger LOG = LoggerFactory.getLogger(AsyncUploadCache.class);

    /**
     * {@link Map} of fileName Vs lastModified to store asynchronous upload.
     */
    Map<String, Long> asyncUploadMap = new HashMap<String, Long>();

    /**
     * {@link Set} of fileName which are mark for delete during asynchronous
     * Upload.
     */
    Set<String> toBeDeleted = new HashSet<String>();

    String path;

    String homeDir;

    int asyncUploadLimit;

    private File pendingUploads;

    private File toBeDeletedUploads;

    private static final String PENDIND_UPLOAD_FILE = "async-pending-uploads.ser";

    private static final String TO_BE_DELETED_UPLOAD_FILE = "async-tobedeleted-uploads.ser";

    /**
     * This methods checks if file can be added to {@link #asyncUploadMap}. If
     * yes it adds to {@link #asyncUploadMap} and
     * {@link #serializeAsyncUploadMap()} the {@link #asyncUploadMap} to disk.
     * 
     * @return {@link AsyncUploadCacheResult} if successfully added to
     *         asynchronous uploads it sets
     *         {@link AsyncUploadCacheResult#setAsyncUpload(boolean)} to true
     *         else sets to false.
     */
    public synchronized AsyncUploadCacheResult add(String fileName)
            throws IOException {
        AsyncUploadCacheResult result = new AsyncUploadCacheResult();
        if (asyncUploadMap.entrySet().size() >= asyncUploadLimit) {
            LOG.info(
                "Async write limit [{}]  reached. File [{}] not added to async write cache.",
                asyncUploadLimit, fileName);
            LOG.debug("AsyncUploadCache size=[{}] and entries =[{}]",
                asyncUploadMap.size(), asyncUploadMap.keySet());
            result.setAsyncUpload(false);
        } else {
            long startTime = System.currentTimeMillis();
            if (toBeDeleted.remove(fileName)) {
                serializeToBeDeleted();
            }
            asyncUploadMap.put(fileName, System.currentTimeMillis());
            serializeAsyncUploadMap();
            LOG.debug("added file [{}] to asyncUploadMap upoad took [{}] sec",
                fileName, ((System.currentTimeMillis() - startTime) / 1000));
            LOG.debug("AsyncUploadCache size=[{}] and entries =[{}]",
                asyncUploadMap.size(), asyncUploadMap.keySet());
            result.setAsyncUpload(true);
        }
        return result;
    }

    /**
     * This methods removes file (if found) from {@link #asyncUploadMap}. If
     * file is found, it immediately serializes the {@link #asyncUploadMap} to
     * disk. This method sets
     * {@link AsyncUploadCacheResult#setRequiresDelete(boolean)} to true, if
     * asynchronous upload found to be in {@link #toBeDeleted} set i.e. marked
     * for delete.
     */
    public synchronized AsyncUploadCacheResult remove(String fileName)
            throws IOException {
        long startTime = System.currentTimeMillis();
        Long retVal = asyncUploadMap.remove(fileName);
        if (retVal != null) {
            serializeAsyncUploadMap();
            LOG.debug("removed file [{}] from asyncUploadMap took [{}] sec",
                fileName, ((System.currentTimeMillis() - startTime) / 1000));
            LOG.debug("AsyncUploadCache size=[{}] and entries =[{}]",
                asyncUploadMap.size(), asyncUploadMap.keySet());
        } else {
            LOG.debug("cannot removed file [{}] from asyncUploadMap took [{}] sec. File not found.",
                fileName, ((System.currentTimeMillis() - startTime) / 1000));
            LOG.debug("AsyncUploadCache size=[{}] and entries =[{}]",
                asyncUploadMap.size(), asyncUploadMap.keySet());
        }
        AsyncUploadCacheResult result = new AsyncUploadCacheResult();
        result.setRequiresDelete(toBeDeleted.contains(fileName));
        return result;
    }

    /**
     * This methods returns the in progress asynchronous uploads which are not
     * marked for delete.
     */
    public synchronized Set<String> getAll() {
        Set<String> retVal = new HashSet<String>();
        retVal.addAll(asyncUploadMap.keySet());
        retVal.removeAll(toBeDeleted);
        return retVal;
    }

    /**
     * This methos checks if asynchronous upload is in progress for @param
     * fileName. If @param touch is true, the lastModified is updated to current
     * time.
     */
    public synchronized boolean hasEntry(String fileName, boolean touch)
            throws IOException {
        boolean contains = asyncUploadMap.containsKey(fileName)
            && !toBeDeleted.contains(fileName);
        if (touch && contains) {
            long timeStamp = System.currentTimeMillis();
            asyncUploadMap.put(fileName, timeStamp);
            serializeAsyncUploadMap();
        }
        return contains;
    }

    /**
     * Returns lastModified from {@link #asyncUploadMap} if found else returns
     * 0.
     */
    public synchronized long getLastModified(String fileName) {
        return asyncUploadMap.get(fileName) != null
            && !toBeDeleted.contains(fileName)
                ? asyncUploadMap.get(fileName)
                : 0;
    }

    /**
     * This methods deletes asynchronous upload for @param fileName if there
     * exists asynchronous upload for @param fileName.
     */
    public synchronized void delete(String fileName) throws IOException {
        boolean serialize = false;
        if (toBeDeleted.remove(fileName)) {
            serialize = true;
        }
        if (asyncUploadMap.containsKey(fileName) && toBeDeleted.add(fileName)) {
            serialize = true;
        }
        if (serialize) {
            serializeToBeDeleted();
        }
    }

    /**
     * Delete in progress asynchronous uploads which are older than @param min.
     * This method leverage lastModified stored in {@link #asyncUploadMap}
     */
    public synchronized Set<String> deleteOlderThan(long min)
            throws IOException {
        min = min - 1000;
        LOG.info("deleteOlderThan min [{}]", min);
        Set<String> deleteSet = new HashSet<String>();
        for (Map.Entry<String, Long> entry : asyncUploadMap.entrySet()) {
            if (entry.getValue() < min) {
                deleteSet.add(entry.getKey());
            }
        }
        if (deleteSet.size() > 0) {
            LOG.debug("deleteOlderThan set [{}]", deleteSet);
            toBeDeleted.addAll(deleteSet);
            serializeToBeDeleted();
        }
        return deleteSet;
    }

    /**
     * @param homeDir
     *            home directory of repository.
     * @param path
     *            path of the {@link LocalCache}
     * @param asyncUploadLimit
     *            the maximum number of asynchronous uploads
     */
    public synchronized void init(String homeDir, String path,
            int asyncUploadLimit) throws IOException, ClassNotFoundException {
        this.homeDir = homeDir;
        this.path = path;
        this.asyncUploadLimit = asyncUploadLimit;
        LOG.info(
            "AsynWriteCache:homeDir=[{}], path=[{}], asyncUploadLimit=[{}].",
            new Object[] { homeDir, path, asyncUploadLimit });
        pendingUploads = new File(homeDir + "/" + PENDIND_UPLOAD_FILE);
        if (pendingUploads.exists()) {
            deserializeAsyncUploadMap();
        } else {
            pendingUploads.createNewFile();
            asyncUploadMap = new HashMap<String, Long>();
            serializeAsyncUploadMap();
        }
        toBeDeletedUploads = new File(homeDir + "/" + TO_BE_DELETED_UPLOAD_FILE);
        if (toBeDeletedUploads.exists()) {
            deserializeToBeDeleted();
        } else {
            toBeDeletedUploads.createNewFile();
            asyncUploadMap = new HashMap<String, Long>();
            serializeToBeDeleted();
        }
    }

    /**
     * Reset the {@link AsyncUploadCache} to empty {@link #asyncUploadMap} and
     * {@link #toBeDeleted}
     */
    public synchronized void reset() throws IOException {
        String filePath = pendingUploads.getAbsolutePath();
        if (pendingUploads.exists()) {
            if (!pendingUploads.delete()) {
                throw new IOException("Failed to delete file [" + filePath
                    + "]");
            }
        }
        pendingUploads.createNewFile();
        asyncUploadMap = new HashMap<String, Long>();
        serializeAsyncUploadMap();

        filePath = toBeDeletedUploads.getAbsolutePath();
        if (toBeDeletedUploads.exists()) {
            if (!toBeDeletedUploads.delete()) {
                throw new IOException("Failed to delete file [" + filePath
                    + "]");
            }
        }
        toBeDeletedUploads.createNewFile();
        toBeDeleted = new HashSet<String>();
        serializeToBeDeleted();
    }

    /**
     * Serialize {@link #asyncUploadMap} to local file system.
     */
    private synchronized void serializeAsyncUploadMap() throws IOException {

        // use buffering
        OutputStream fos = new FileOutputStream(pendingUploads);
        OutputStream buffer = new BufferedOutputStream(fos);
        ObjectOutput output = new ObjectOutputStream(buffer);
        try {
            output.writeObject(asyncUploadMap);
        } finally {
            output.close();
        }
    }

    /**
     * Deserialize {@link #asyncUploadMap} from local file system.
     */
    private synchronized void deserializeAsyncUploadMap() throws IOException,
            ClassNotFoundException {
        // use buffering
        InputStream fis = new FileInputStream(pendingUploads);
        InputStream buffer = new BufferedInputStream(fis);
        ObjectInput input = new ObjectInputStream(buffer);
        try {
            asyncUploadMap = (Map<String, Long>) input.readObject();
            // display its data
        } finally {
            input.close();
        }
    }

    /**
     * Serialize {@link #toBeDeleted} to local file system.
     */
    private synchronized void serializeToBeDeleted() throws IOException {

        // use buffering
        OutputStream fos = new FileOutputStream(toBeDeletedUploads);
        OutputStream buffer = new BufferedOutputStream(fos);
        ObjectOutput output = new ObjectOutputStream(buffer);
        try {
            output.writeObject(toBeDeleted);
        } finally {
            output.close();
        }
    }

    /**
     * Deserialize {@link #toBeDeleted} from local file system.
     */
    private synchronized void deserializeToBeDeleted() throws IOException,
            ClassNotFoundException {
        // use buffering
        InputStream fis = new FileInputStream(toBeDeletedUploads);
        InputStream buffer = new BufferedInputStream(fis);
        ObjectInput input = new ObjectInputStream(buffer);
        try {
            toBeDeleted = (Set<String>) input.readObject();
        } finally {
            input.close();
        }
    }
}
