/*
 * #%L
 * Alfresco Repository
 * %%
 * Copyright (C) 2005 - 2016 Alfresco Software Limited
 * %%
 * This file is part of the Alfresco software. 
 * If the software was purchased under a paid Alfresco license, the terms of 
 * the paid license agreement will prevail.  Otherwise, the software is 
 * provided under the following open source license terms:
 * 
 * Alfresco is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Lesser General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 * 
 * Alfresco is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU Lesser General Public License for more details.
 * 
 * You should have received a copy of the GNU Lesser General Public License
 * along with Alfresco. If not, see <http://www.gnu.org/licenses/>.
 * #L%
 */
package org.alfresco.repo.content.caching.cleanup;

import java.io.File;
import java.util.Date;

import org.alfresco.repo.content.caching.CacheFileProps;
import org.alfresco.repo.content.caching.ContentCacheImpl;
import org.alfresco.repo.content.caching.FileHandler;
import org.alfresco.repo.content.caching.quota.UsageTracker;
import org.alfresco.util.Deleter;
import org.apache.commons.io.FileUtils;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.beans.factory.annotation.Required;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.ApplicationEventPublisherAware;

/**
 * Cleans up redundant cache files from the cached content file store. Once references to cache files are
 * no longer in the in-memory cache, the binary content files can be removed.
 * 
 * @author Matt Ward
 */
public class CachedContentCleaner extends Thread implements FileHandler, ApplicationEventPublisherAware
{
    private static final Log log = LogFactory.getLog(CachedContentCleaner.class);
    private ContentCacheImpl cache;   // impl specific functionality required
    private long minFileAgeMillis = 0;
    private Integer maxDeleteWatchCount = 1;
    private boolean running;
    private UsageTracker usageTracker;
    private long newDiskUsage;
    private long numFilesSeen;
    private long numFilesDeleted;
    private long sizeFilesDeleted;
    private long numFilesMarked;
    private Date timeStarted;
    private Date timeFinished;
    private ApplicationEventPublisher eventPublisher;
    private long targetReductionBytes;
    private String reasonMessage;
   
    
    public CachedContentCleaner()
    {        
        setName(getClass().getSimpleName());
        setDaemon(true);
    }
    
    /**
     * This method MUST be called after the cleaner has been fully constructed
     * to notify interested parties that the cleaner exists and to start the actual cleaner thread.
     */
    public void init()
    {
        eventPublisher.publishEvent(new CachedContentCleanerCreatedEvent(this));    
        start();
    }
    
    
    @Override
    public void run()
    {
        while (true)
        {
            doClean();
        }
    }

    public synchronized void execute()
    {
        execute("none specified");
    }
    
    public synchronized void executeAggressive(String reason, long targetReductionBytes)
    {
        this.targetReductionBytes = targetReductionBytes;
        execute(reason);
    }
    
    private void doClean()
    {   
        synchronized(this)
        {
            try
            {
                wait();
            }
            catch (InterruptedException error)
            {
                // Nothing to do.
            }
        }
        running = true;
        if (log.isInfoEnabled())
        {
            log.info("Starting cleaner, reason: " + reasonMessage);
        }
        resetStats();
        timeStarted = new Date();
        cache.processFiles(this);
        timeFinished = new Date(); 
        
        if (usageTracker != null)
        {
            usageTracker.setCurrentUsageBytes(newDiskUsage);
        }
        
        if (log.isInfoEnabled())
        {
            log.info("Finished, duration: " + getDurationSeconds() + "s, seen: " + numFilesSeen +
                        ", marked: " + numFilesMarked +
                        ", deleted: " + numFilesDeleted +
                        " (" + String.format("%.2f", getSizeFilesDeletedMB()) + "MB, " +
                        sizeFilesDeleted + " bytes)" +
                        ", target: " + targetReductionBytes + " bytes");
        }
        
        this.targetReductionBytes = 0;
        running = false;
        
        synchronized(this)
        {
            notifyAll();
        }
    }
    
    
    public synchronized void execute(String reasonMessage)
    {
        this.reasonMessage = reasonMessage;
        notifyAll();
    }
    
    
    private void resetStats()
    {
        newDiskUsage = 0;
        numFilesSeen = 0;
        numFilesDeleted = 0;
        sizeFilesDeleted = 0;
        numFilesMarked = 0;
    }


    @Override
    public void handle(File cachedContentFile)
    {
        if (log.isDebugEnabled())
        {
            log.debug("handle file: " + cachedContentFile + " (target reduction: " + targetReductionBytes + " bytes)");
        }
        numFilesSeen++;
        CacheFileProps props = null;
        boolean deleted = false;
        
        if (targetReductionBytes > 0 && sizeFilesDeleted < targetReductionBytes)
        {
            if (log.isDebugEnabled())
            {
                log.debug("Target reduction " + targetReductionBytes +
                           " bytes not yet reached. Deleted so far: " + sizeFilesDeleted);
            }
            // Aggressive clean mode, delete file straight away.
            deleted = deleteFilesNow(cachedContentFile);
        }
        else
        {
            if (oldEnoughForCleanup(cachedContentFile))
            {
                if (log.isDebugEnabled())
                {
                    log.debug("File is older than " + minFileAgeMillis + 
                                "ms - considering for cleanup: " + cachedContentFile);
                }
                props = new CacheFileProps(cachedContentFile);        
                String url = cache.getContentUrl(cachedContentFile);
                if (url == null)
                {
                    // Not in the cache, check the properties file 
                    props.load();
                    url = props.getContentUrl();
                }   
                
                if (url == null || !cache.contains(url))
                {
                    // If the url is null, it might still be in the cache, but we were unable to determine it
                    // from the reverse lookup or the properties file. Delete the file as it is most likely orphaned.
                    // If for some reason it is still in the cache, cache.getReader(url) must re-cache it.
                    deleted = markOrDelete(cachedContentFile, props);
                }
            }
            else
            {
                if (log.isDebugEnabled())
                {
                    log.debug("File too young for cleanup - ignoring " + cachedContentFile);
                }
            }
        }
        
        if (!deleted)
        {
            if (props == null)
            {
                props = new CacheFileProps(cachedContentFile);
            }
            long size = cachedContentFile.length() + props.fileSize();
            newDiskUsage += size;
        }
    }

    

    /**
     * Is the file old enough to be considered for cleanup/deletion? The file must be older than minFileAgeMillis
     * to be considered for deletion - the state of the cache and the file's associated properties file will not
     * be examined unless the file is old enough.
     *  
     * @return true if the file is older than minFileAgeMillis, false otherwise.
     */
    private boolean oldEnoughForCleanup(File file)
    {
        if (minFileAgeMillis == 0)
        {
            return true;
        }
        else
        {
            long now = System.currentTimeMillis();
            return (file.lastModified() < (now - minFileAgeMillis));
        }
    }


    /**
     * Marks a file for deletion by a future run of the CachedContentCleaner. Each time a file is observed
     * by the cleaner as being ready for deletion, the deleteWatchCount is incremented until it reaches
     * maxDeleteWatchCount - in which case the next run of cleaner will really delete it.
     * <p>
     * For maxDeleteWatchCount of 1 for example, the first cleaner run will mark the file for deletion and the second
     * run will really delete it.
     * <p>
     * This offers a degree of protection over the fairly unlikely event that a reader will be obtained for a file that
     * is in the cache but gets removed from the cache and is then deleted by the cleaner before
     * the reader was consumed. A maxDeleteWatchCount of 1 should normally be fine (recommended), whilst
     * 0 would result in immediate deletion the first time the cleaner sees it as a candidate
     * for deletion (not recommended).
     * 
     * @param file File
     * @param props CacheFileProps
     * @return true if the content file was deleted, false otherwise.
     */
    private boolean markOrDelete(File file, CacheFileProps props)
    {
        Integer deleteWatchCount = props.getDeleteWatchCount();

        // Just in case the value has been corrupted somehow.
        if (deleteWatchCount < 0)
            deleteWatchCount = 0;
        
        boolean deleted = false;
        
        if (deleteWatchCount < maxDeleteWatchCount)
        {
            deleteWatchCount++;
            
            if (log.isDebugEnabled())
            {
                log.debug("Marking file for deletion, deleteWatchCount=" + deleteWatchCount + ", file: "+ file);
            }
            props.setDeleteWatchCount(deleteWatchCount);
            props.store();
            numFilesMarked++;
        }
        else
        {
            if (log.isDebugEnabled())
            {
                log.debug("Deleting cache file " + file);
            }
            deleted = deleteFilesNow(file);
        }
        
        return deleted;
    }

    /**
     * Deletes both the cached content file and its peer properties file that contains the
     * original content URL and deletion marker information.
     *  
     * @param cacheFile Location of cached content file.
     * @return true if the content file was deleted, false otherwise.
     */
    private boolean deleteFilesNow(File cacheFile)
    {
        CacheFileProps props = new CacheFileProps(cacheFile);
        props.delete();
        long fileSize = cacheFile.length();
        boolean deleted = cacheFile.delete();
        if (deleted)
        {
            if (log.isTraceEnabled())
            {
                log.trace("Deleted cache file: " + cacheFile);
            }
            numFilesDeleted++;
            sizeFilesDeleted += fileSize;
            Deleter.deleteEmptyParents(cacheFile, cache.getCacheRoot());
        }
        else
        {
            if (log.isWarnEnabled())
            {
                log.warn("Failed to delete cache file: " + cacheFile);
            }
        }
        
        return deleted;
    }

    

    @Required
    public void setCache(ContentCacheImpl cache)
    {
        this.cache = cache;
    }

    
    /**
     * Sets the minimum age of a cache file before it will be considered for deletion.
     * @see #oldEnoughForCleanup(File)
     * @param minFileAgeMillis long
     */
    public void setMinFileAgeMillis(long minFileAgeMillis)
    {
        this.minFileAgeMillis = minFileAgeMillis;
    }


    /**
     * Sets the maxDeleteWatchCount value.
     * 
     * @see #markOrDelete(File, CacheFileProps)
     * @param maxDeleteWatchCount Integer
     */
    public void setMaxDeleteWatchCount(Integer maxDeleteWatchCount)
    {
        if (maxDeleteWatchCount < 0)
        {
            throw new IllegalArgumentException("maxDeleteWatchCount cannot be negative [value=" + maxDeleteWatchCount + "]");
        }
        this.maxDeleteWatchCount = maxDeleteWatchCount;
    }


    /**
     * @param usageTracker the usageTracker to set
     */
    public void setUsageTracker(UsageTracker usageTracker)
    {
        this.usageTracker = usageTracker;
    }

    public boolean isRunning()
    {
        return running;
    }
    
    public long getNumFilesSeen()
    {
        return this.numFilesSeen;
    }
    
    public long getNumFilesDeleted()
    {
        return this.numFilesDeleted;
    }

    public long getSizeFilesDeleted()
    {
        return this.sizeFilesDeleted;
    }
        
    public double getSizeFilesDeletedMB()
    {
        return (double) getSizeFilesDeleted() / FileUtils.ONE_MB;
    }

    public long getNumFilesMarked()
    {
        return numFilesMarked;
    }
    
    public Date getTimeStarted()
    {
        return this.timeStarted;
    }

    public Date getTimeFinished()
    {
        return this.timeFinished;
    }
    
    public long getDurationSeconds()
    {
        return getDurationMillis() / 1000;
    }
    
    public long getDurationMillis()
    {
        return timeFinished.getTime() - timeStarted.getTime();
    }

    @Override
    public void setApplicationEventPublisher(ApplicationEventPublisher eventPublisher)
    {
        this.eventPublisher = eventPublisher;
    }

    /**
     * Returns the cacheRoot that this cleaner is responsible for.
     * @return File
     */
    public File getCacheRoot()
    {
        return cache.getCacheRoot();
    }
}
