/*
 * #%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.filestore;

import java.io.File;
import java.io.IOException;
import java.io.Serializable;
import java.util.Collections;
import java.util.Map;

import org.alfresco.api.AlfrescoPublicApi;
import org.alfresco.error.AlfrescoRuntimeException;
import org.alfresco.repo.content.AbstractContentStore;
import org.alfresco.repo.content.ContentContext;
import org.alfresco.repo.content.ContentStore;
import org.alfresco.repo.content.ContentStoreCreatedEvent;
import org.alfresco.repo.content.EmptyContentReader;
import org.alfresco.repo.content.UnsupportedContentUrlException;
import org.alfresco.service.cmr.repository.ContentIOException;
import org.alfresco.service.cmr.repository.ContentReader;
import org.alfresco.service.cmr.repository.ContentWriter;
import org.alfresco.util.Deleter;
import org.alfresco.util.Pair;
import org.apache.commons.io.FilenameUtils;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.beans.BeansException;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.context.ApplicationEvent;
import org.springframework.context.ApplicationListener;
import org.springframework.context.event.ContextRefreshedEvent;

/**
 * Provides a store of node content directly to the file system.  The writers
 * are generated using information from the {@link ContentContext simple content context}.
 * <p>
 * The file names obey, as they must, the URL naming convention
 * as specified in the {@link org.alfresco.repo.content.ContentStore ContentStore interface}.<br/>
 * The protocols handled are:
 * <ul>
 *   <li><b>{@link #STORE_PROTOCOL store}</b>: These URLs can be generated by this implementation and are file references within the root directory.</li>
 *   <li><b>{@link #SPOOF_PROTOCOL spoof}</b>: These URLs are never generated by the implementation but represent spoofed binary text stream data.</li>
 * </ul>
 * 
 * @author Derek Hulley
 */
@AlfrescoPublicApi
public class FileContentStore
        extends AbstractContentStore
        implements ApplicationContextAware, ApplicationListener<ApplicationEvent>
{
    /**
     * <b>store</b> is the default prefix for file content URLs
     * @see ContentStore#PROTOCOL_DELIMITER
     */
    public static final String STORE_PROTOCOL = "store";
    public static final String SPOOF_PROTOCOL = "spoof";
    
    private static final Log logger = LogFactory.getLog(FileContentStore.class);
    
    private File rootDirectory;
    private String rootAbsolutePath;
    private boolean allowRandomAccess;
    private boolean readOnly;
    private ApplicationContext applicationContext;
    private boolean deleteEmptyDirs = true;
    private FileContentUrlProvider fileContentUrlProvider = new TimeBasedFileContentUrlProvider();

    /**
     * Private: for Spring-constructed instances only.
     * 
     * @param rootDirectoryStr
     *            the root under which files will be stored. The directory will be created if it does not exist.
     * @see FileContentStore#FileContentStore(File)
     */
    /*package*/ FileContentStore(String rootDirectoryStr)
    {
        this(new File(rootDirectoryStr));
    }

    /**
     * Private: for Spring-constructed instances only.
     * 
     * @param rootDirectory
     *            the root under which files will be stored. The directory will be created if it does not exist.
     */
    private FileContentStore(File rootDirectory)
    {
        if (!rootDirectory.exists())
        {
            if (!rootDirectory.mkdirs())
            {
                throw new ContentIOException("Failed to create store root: " + rootDirectory, null);
            }
        }
        this.rootDirectory = rootDirectory.getAbsoluteFile();
        rootAbsolutePath = rootDirectory.getAbsolutePath();
        allowRandomAccess = true;
        readOnly = false;
    }
    
    /**
     * Public constructor for programmatic use.
     * 
     * @param context
     *            application context through which events can be published
     * @param rootDirectoryStr
     *            the root under which files will be stored. The directory will be created if it does not exist.
     * @see FileContentStore#FileContentStore(File)
     */
    public FileContentStore(ApplicationContext context, String rootDirectoryStr)
    {
        this(rootDirectoryStr);
        setApplicationContext(context);
        publishEvent(context, Collections.<String, Serializable> emptyMap());
    }

    /**
     * Public constructor for programmatic use.
     * 
     * @param context
     *            application context through which events can be published
     * @param rootDirectory
     *            the root under which files will be stored. The directory will be created if it does not exist.
     */
    public FileContentStore(ApplicationContext context, File rootDirectory)
    {
        this(rootDirectory);        
        setApplicationContext(context);
        publishEvent(context, Collections.<String, Serializable> emptyMap());
    }
    
    public FileContentStore(ApplicationContext context, File rootDirectory, Map<String, Serializable> extendedEventParams)
    {
        this(rootDirectory);        
        setApplicationContext(context);
        publishEvent(context, extendedEventParams);
    }
    
    
    public String toString()
    {
        StringBuilder sb = new StringBuilder(36);
        sb.append("FileContentStore")
          .append("[ root=").append(rootDirectory)
          .append(", allowRandomAccess=").append(allowRandomAccess)
          .append(", readOnly=").append(readOnly)
          .append("]");
        return sb.toString();
    }

    public void setApplicationContext(ApplicationContext applicationContext) throws BeansException
    {
        this.applicationContext = applicationContext;
    }

    /**
     * Stores may optionally produce readers and writers that support random access.
     * Switch this off for this store by setting this to <tt>false</tt>.
     * <p>
     * This switch is primarily used during testing to ensure that the system has the
     * ability to spoof random access in cases where the store is unable to produce
     * readers and writers that allow random access.  Typically, stream-based access
     * would be an example.
     * 
     * @param allowRandomAccess true to allow random access, false to have it faked
     */
    public void setAllowRandomAccess(boolean allowRandomAccess)
    {
        this.allowRandomAccess = allowRandomAccess;
    }

    /**
     * File stores may optionally be declared read-only.  This is useful when configuring
     * a store, possibly temporarily, to act as a source of data but to preserve it against
     * any writes.
     * 
     * @param readOnly      <tt>true</tt> to force the store to only allow reads.
     */
    public void setReadOnly(boolean readOnly)
    {
        this.readOnly = readOnly;
    }
    
    public void setFileContentUrlProvider(FileContentUrlProvider fileContentUrlProvider)
    {
        this.fileContentUrlProvider = fileContentUrlProvider;
    }

    /**
     * Generates a new URL and file appropriate to it.
     * 
     * @return Returns a new and unique file
     * @throws IOException if the file or parent directories couldn't be created
     */
    /*package*/ File createNewFile() throws IOException
    {
        String contentUrl = fileContentUrlProvider.createNewFileStoreUrl();
        return createNewFile(contentUrl);
    }
    
    /**
     * Creates a file for the specifically provided content URL.  The URL may
     * not already be in use.
     * <p>
     * The store prefix is stripped off the URL and the rest of the URL
     * used directly to create a file.
     * 
     * @param newContentUrl the specific URL to use, which may not be in use
     * @return Returns a new and unique file
     * @throws IOException
     *             if the file or parent directories couldn't be created or if the URL is already in use.
     * @throws UnsupportedOperationException
     *             if the store is read-only
     * 
     * @see #setReadOnly(boolean)
     */
    /*package*/ File createNewFile(String newContentUrl) throws IOException
    {
        if (readOnly)
        {
            throw new UnsupportedOperationException("This store is currently read-only: " + this);
        }
        
        File file = makeFile(newContentUrl);

        // create the directory, if it doesn't exist
        File dir = file.getParentFile();
        if (!dir.exists())
        {
            makeDirectory(dir);
        }
        
        // create a new, empty file
        boolean created = file.createNewFile();
        if (!created)
        {
            throw new ContentIOException(
                    "When specifying a URL for new content, the URL may not be in use already. \n" +
                    "   store: " + this + "\n" +
                    "   new URL: " + newContentUrl);
        }
        
        // done
        return file;
    }
    
    /**
     * Synchronized and retrying directory creation.  Repeated attempts will be made to create the
     * directory, subject to a limit on the number of retries.
     * 
     * @param dir               the directory to create
     * @throws IOException      if an IO error occurs
     */
    private synchronized void makeDirectory(File dir) throws IOException
    {
        /*
         * Once in this method, the only contention will be from other file stores or processes.
         * This is OK as we have retrying to sort it out.
         */
        if (dir.exists())
        {
            // Beaten to it during synchronization
            return;
        }
        // 20 attempts with 20 ms wait each time
        for (int i = 0; i < 20; i++)
        {
            boolean created = dir.mkdirs();
            if (created)
            {
                // Successfully created
                return;
            }
            // Wait
            try { this.wait(20L); } catch (InterruptedException e) {}
            // Did it get created in the meantime
            if (dir.exists())
            {
                // Beaten to it while asleep
                return;
            }
        }
        // It still didn't succeed
        throw new ContentIOException("Failed to create directory for file storage: " +  dir);
    }
    
    /**
     * Takes the file absolute path, strips off the root path of the store
     * and appends the store URL prefix.
     * 
     * @param file the file from which to create the URL
     * @return Returns the equivalent content URL
     */
    /*package*/ String makeContentUrl(File file)
    {
        String path = file.getAbsolutePath();
        // check if it belongs to this store
        if (!path.startsWith(rootAbsolutePath))
        {
            throw new AlfrescoRuntimeException(
                    "File does not fall below the store's root: \n" +
                    "   file: " + file + "\n" +
                    "   store: " + this);
        }
        // strip off the file separator char, if present
        int index = rootAbsolutePath.length();
        if (path.charAt(index) == File.separatorChar)
        {
            index++;
        }
        // strip off the root path and adds the protocol prefix
        String url = FileContentStore.STORE_PROTOCOL + ContentStore.PROTOCOL_DELIMITER + path.substring(index);
        // replace '\' with '/' so that URLs are consistent across all filesystems
        url = url.replace('\\', '/');
        // done
        return url;
    }
    
    /**
     * Creates a file from the given relative URL.
     * 
     * @param contentUrl    the content URL including the protocol prefix
     * @return              Returns a file representing the URL - the file may or may not
     *                      exist
     * @throws UnsupportedContentUrlException
     *                      if the URL is invalid and doesn't support the
     *                      {@link FileContentStore#STORE_PROTOCOL correct protocol}
     */
    /*package*/ File makeFile(String contentUrl)
    {
        // take just the part after the protocol
        Pair<String, String> urlParts = super.getContentUrlParts(contentUrl);
        String protocol = urlParts.getFirst();
        String relativePath = urlParts.getSecond();
        // Check the protocol
        if (!protocol.equals(FileContentStore.STORE_PROTOCOL))
        {
            throw new UnsupportedContentUrlException(this, protocol + PROTOCOL_DELIMITER + relativePath);
        }
        // get the file
        File file = new File(rootDirectory, relativePath);
        
        ensureFileInContentStore(file);
        
        // done
        return file;
    }

    /**
     * @return Returns <tt>true</tt> always
     */
    public boolean isWriteSupported()
    {
        return !readOnly;
    }

    /**
     * Performs a direct check against the file for its existence.
     * For {@link #SPOOF_PROTOCOL spoofed} URLs, the URL always exists.
     */
    @Override
    public boolean exists(String contentUrl)
    {
        if (contentUrl.startsWith(SPOOF_PROTOCOL))
        {
            return true;
        }
        else
        {
            File file = makeFile(contentUrl);
            return file.exists();
        }
    }

    /**
     * Get the filesystem's free space.
     * 
     * @return          Returns the root directory partition's {@link File#getFreeSpace() free space}
     */
    @Override
    public long getSpaceFree()
    {
        return rootDirectory.getFreeSpace();
    }

    /**
     * Get the filesystem's total space.
     * 
     * @return          Returns the root directory partition's {@link File#getTotalSpace() total space}
     */
    @Override
    public long getSpaceTotal()
    {
        return rootDirectory.getTotalSpace();
    }

    /**
     * @return          Returns the canonical path to the root directory
     */
    @Override
    public String getRootLocation()
    {
        try
        {
            return rootDirectory.getCanonicalPath();
        }
        catch (Throwable e)
        {
            logger.warn("Unabled to return root location", e);
            return super.getRootLocation();
        }
    }

    /**
     * This implementation requires that the URL start with
     * {@link FileContentStore#STORE_PROTOCOL } or {@link FileContentStore#SPOOF_PROTOCOL }
     */
    public ContentReader getReader(String contentUrl)
    {
        // Handle the spoofed URL
        if (contentUrl.startsWith(SPOOF_PROTOCOL))
        {
            return new SpoofedTextContentReader(contentUrl);
        }
        // else, it's a real file we are after
        try
        {
            File file = makeFile(contentUrl);
            ContentReader reader = null;
            if (file.exists())
            {
                FileContentReader fileContentReader = new FileContentReader(file, contentUrl);
                fileContentReader.setAllowRandomAccess(allowRandomAccess);
                reader = fileContentReader;
            }
            else
            {
                reader = new EmptyContentReader(contentUrl);
            }
            
            // done
            if (logger.isDebugEnabled())
            {
                logger.debug("Created content reader: \n" +
                        "   url: " + contentUrl + "\n" +
                        "   file: " + file + "\n" +
                        "   reader: " + reader);
            }
            return reader;
        }
        catch (UnsupportedContentUrlException e)
        {
            // This can go out directly
            throw e;
        }
        catch (Throwable e)
        {
            throw new ContentIOException("Failed to get reader for URL: " + contentUrl, e);
        }
    }
    
    /**
     * Returns a writer onto a location based on the date.
     * 
     * @param existingContentReader
     *            the existing content reader
     * @param newContentUrl
     *            the new content url
     * @return Returns a writer onto a location based on the date
     */
    public ContentWriter getWriterInternal(ContentReader existingContentReader, String newContentUrl)
    {
        try
        {
            File file = null;
            String contentUrl = null;
            if (newContentUrl == null)              // a specific URL was not supplied
            {
                // get a new file with a new URL
                file = createNewFile();
                // make a URL
                contentUrl = makeContentUrl(file);
            }
            else                                    // the URL has been given
            {
                file = createNewFile(newContentUrl);
                contentUrl = newContentUrl;
            }
            // create the writer
            FileContentWriter writer = new FileContentWriter(file, contentUrl, existingContentReader);
            
            if (contentLimitProvider != null)
            {
                writer.setContentLimitProvider(contentLimitProvider);
            }
            writer.setAllowRandomAccess(allowRandomAccess);
            
            // done
            if (logger.isDebugEnabled())
            {
                logger.debug("Created content writer: \n" +
                        "   writer: " + writer);
            }
            return writer;
        }
        catch (IOException e)
        {
            throw new ContentIOException("Failed to get writer", e);
        }
    }
    
    /**
     * Attempts to delete the content.  The actual deletion is optional on the interface
     * so it just returns the success or failure of the underlying delete.
     * 
     * @throws UnsupportedOperationException        if the store is read-only
     * 
     * @see #setReadOnly(boolean)
     */
    public boolean delete(String contentUrl)
    {
        if (readOnly)
        {
            throw new UnsupportedOperationException("This store is currently read-only: " + this);
        }
        if (contentUrl.startsWith(SPOOF_PROTOCOL))
        {
            // This is not a failure but the content can never actually be deleted
            return false;
        }
        // Handle regular files based on the real files
        File file = makeFile(contentUrl);
        boolean deleted = false;
        if (!file.exists())
        {
            // File does not exist
            deleted = true;
        }
        else
        {
            deleted = file.delete();
        }
        
        // Delete empty parents regardless of whether the file was ignore above.
        if (deleteEmptyDirs && deleted)
        {
            Deleter.deleteEmptyParents(file, getRootLocation());
        }

        // done
        if (logger.isDebugEnabled())
        {
            logger.debug("Delete content directly: \n" +
                    "   store: " + this + "\n" +
                    "   url: " + contentUrl);
        }
        return deleted;
    }

    /**
     * Creates a new content URL.  This must be supported by all
     * stores that are compatible with Alfresco.
     * 
     * @return Returns a new and unique content URL
     */
    public static String createNewFileStoreUrl()
    {
        return TimeBasedFileContentUrlProvider.createNewFileStoreUrl(0);
    }

    /**
     * Publishes an event to the application context that will notify any interested parties of the existence of this
     * content store.
     * 
     * @param context
     *            the application context
     * @param extendedEventParams 
     */
    private void publishEvent(ApplicationContext context, Map<String, Serializable> extendedEventParams)
    {
        context.publishEvent(new ContentStoreCreatedEvent(this, extendedEventParams));
    }

    public void onApplicationEvent(ApplicationEvent event)
    {
        // Once the context has been refreshed, we tell other interested beans about the existence of this content store
        // (e.g. for monitoring purposes)
        if (event instanceof ContextRefreshedEvent && event.getSource() == this.applicationContext)
        {
            publishEvent(((ContextRefreshedEvent) event).getApplicationContext(), Collections.<String, Serializable> emptyMap());
        }
    }

    /**
     * Configure the FileContentStore to delete empty parent directories upon deleting a content URL.
     * 
     * @param deleteEmptyDirs the deleteEmptyDirs to set
     */
    public void setDeleteEmptyDirs(boolean deleteEmptyDirs)
    {
        this.deleteEmptyDirs = deleteEmptyDirs;
    }
    
    /*
     * Added as fix for MNT-12301, we should ensure that content store accesses content only inside of store root
     */
    private void ensureFileInContentStore(File file)
    {
        String fileNormalizedAbsoultePath = FilenameUtils.normalize(file.getAbsolutePath());
        String rootNormalizedAbsolutePath = FilenameUtils.normalize(rootAbsolutePath);
        
        if (!fileNormalizedAbsoultePath.startsWith(rootNormalizedAbsolutePath))
        {
            throw new ContentIOException("Access to files outside of content store root is not allowed: " + file);
        }
    }
}
