/*
 * Copyright 2017 - 2020 Acosix GmbH
 *
 * Licensed 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 de.acosix.alfresco.simplecontentstores.repo.store.file;

import java.io.File;
import java.io.IOException;
import java.io.Serializable;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.FileAttribute;
import java.nio.file.attribute.PosixFilePermission;
import java.nio.file.attribute.PosixFilePermissions;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.stream.Stream;

import org.alfresco.error.AlfrescoRuntimeException;
import org.alfresco.repo.content.AbstractContentStore;
import org.alfresco.repo.content.ContentLimitProvider;
import org.alfresco.repo.content.ContentLimitProvider.SimpleFixedLimitProvider;
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.repo.content.filestore.FileContentUrlProvider;
import org.alfresco.repo.content.filestore.SpoofedTextContentReader;
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.Pair;
import org.alfresco.util.PropertyCheck;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.context.ApplicationListener;
import org.springframework.context.event.ContextRefreshedEvent;
import org.springframework.extensions.surf.util.ParameterCheck;

import de.acosix.alfresco.simplecontentstores.repo.store.ContentUrlUtils;
import de.acosix.alfresco.simplecontentstores.repo.store.StoreConstants;

/**
 * This implementation of a file-based content store is heavily borrowed from {@link org.alfresco.repo.content.filestore.FileContentStore}
 * with adaptions designed to work around limitations of that implementation. This class (and its 90% copied code) wouldn't be necessary if
 * Alfresco didn't excessively use restricting member visibilities.
 *
 * @author Axel Faust
 */
public class FileContentStore extends AbstractContentStore
        implements ApplicationContextAware, ApplicationListener<ContextRefreshedEvent>, InitializingBean
{

    protected static final String STORE_PROTOCOL = org.alfresco.repo.content.filestore.FileContentStore.STORE_PROTOCOL;

    protected static final String SPOOF_PROTOCOL = org.alfresco.repo.content.filestore.FileContentStore.SPOOF_PROTOCOL;

    private static final Logger LOGGER = LoggerFactory.getLogger(FileContentStore.class);

    protected ApplicationContext applicationContext;

    protected Map<String, Serializable> extendedEventParameters;

    protected transient File rootDirectory;

    protected String rootAbsolutePath;

    protected String protocol = STORE_PROTOCOL;

    protected boolean allowRandomAccess;

    protected boolean readOnly;

    protected boolean deleteEmptyDirs = true;

    protected FileContentUrlProvider fileContentUrlProvider;

    /**
     *
     * {@inheritDoc}
     */
    @Override
    public void afterPropertiesSet()
    {
        PropertyCheck.mandatory(this, "rootAbsolutePath", this.rootAbsolutePath);
        PropertyCheck.mandatory(this, "protocol", this.protocol);

        if (this.extendedEventParameters == null)
        {
            this.extendedEventParameters = Collections.<String, Serializable> emptyMap();
        }

        this.rootDirectory = new File(this.rootAbsolutePath);
        if (!this.rootDirectory.exists() && !this.rootDirectory.mkdirs())
        {
            throw new ContentIOException("Failed to create store root: " + this.rootDirectory, null);
        }

        if (this.fileContentUrlProvider == null)
        {
            this.fileContentUrlProvider = new TimeBasedFileContentUrlProvider();
            ((TimeBasedFileContentUrlProvider) this.fileContentUrlProvider).setStoreProtocol(this.protocol);
        }
        else
        {
            final String createNewFileStoreUrl = this.fileContentUrlProvider.createNewFileStoreUrl();
            if (!createNewFileStoreUrl.startsWith(this.protocol + ContentStore.PROTOCOL_DELIMITER))
            {
                this.fileContentUrlProvider = new AlternativeProtocolFileContentUrlProviderFacade(this.fileContentUrlProvider,
                        this.protocol);
            }
        }

        this.rootDirectory = this.rootDirectory.getAbsoluteFile();
        this.rootAbsolutePath = this.rootDirectory.getAbsolutePath();

        if (this.applicationContext != null)
        {
            this.applicationContext.publishEvent(new ContentStoreCreatedEvent(this, this.extendedEventParameters));
        }
    }

    /**
     *
     * {@inheritDoc}
     */
    @Override
    public void setApplicationContext(final ApplicationContext applicationContext) throws BeansException
    {
        this.applicationContext = applicationContext;
    }

    /**
     * @param extendedEventParameters
     *            the extendedEventParameters to set
     */
    public void setExtendedEventParameters(final Map<String, Serializable> extendedEventParameters)
    {
        // decouple map
        this.extendedEventParameters = extendedEventParameters != null ? new HashMap<>(extendedEventParameters) : null;
    }

    /**
     * @param rootAbsolutePath
     *            the rootAbsolutePath to set
     */
    public void setRootAbsolutePath(final String rootAbsolutePath)
    {
        this.rootAbsolutePath = rootAbsolutePath;
    }

    /**
     * Simple alias to {@link #setRootAbsolutePath(String)} for compatibility with previously used {@code FileContentStoreFactoryBean}
     *
     * @param rootDirectory
     *            the rootDirectory to set
     */
    public void setRootDirectory(final String rootDirectory)
    {
        this.rootAbsolutePath = rootDirectory;
    }

    /**
     * @param protocol
     *            the protocol to set
     */
    public void setProtocol(final String protocol)
    {
        this.protocol = protocol;
    }

    /**
     * @param allowRandomAccess
     *            the allowRandomAccess to set
     */
    public void setAllowRandomAccess(final boolean allowRandomAccess)
    {
        this.allowRandomAccess = allowRandomAccess;
    }

    /**
     * @param readOnly
     *            the readOnly to set
     */
    public void setReadOnly(final boolean readOnly)
    {
        this.readOnly = readOnly;
    }

    /**
     * @param deleteEmptyDirs
     *            the deleteEmptyDirs to set
     */
    public void setDeleteEmptyDirs(final boolean deleteEmptyDirs)
    {
        this.deleteEmptyDirs = deleteEmptyDirs;
    }

    /**
     *
     * @param limit
     *            the fixed content limit to set
     */
    public void setFixedLimit(final long limit)
    {
        if (limit < 0 && limit != ContentLimitProvider.NO_LIMIT)
        {
            throw new IllegalArgumentException("fixedLimit must be non-negative");
        }
        this.setContentLimitProvider(new SimpleFixedLimitProvider(limit));
    }

    /**
     * @param fileContentUrlProvider
     *            the fileContentUrlProvider to set
     */
    public void setFileContentUrlProvider(final FileContentUrlProvider fileContentUrlProvider)
    {
        this.fileContentUrlProvider = fileContentUrlProvider;
    }

    /**
     *
     * {@inheritDoc}
     */
    @Override
    public void onApplicationEvent(final ContextRefreshedEvent event)
    {
        if (this.extendedEventParameters == null)
        {
            this.extendedEventParameters = Collections.<String, Serializable> emptyMap();
        }

        if (event.getSource() == this.applicationContext)
        {
            final ApplicationContext context = event.getApplicationContext();
            context.publishEvent(new ContentStoreCreatedEvent(this, this.extendedEventParameters));
        }
    }

    /**
     *
     * {@inheritDoc}
     */
    @Override
    public long getSpaceFree()
    {
        return this.rootDirectory.getFreeSpace();
    }

    /**
     *
     * {@inheritDoc}
     */
    @Override
    public long getSpaceTotal()
    {
        return this.rootDirectory.getTotalSpace();
    }

    /**
     *
     * {@inheritDoc}
     */
    @Override
    public String getRootLocation()
    {
        String rootLocation;
        try
        {
            rootLocation = this.rootDirectory.getCanonicalPath();
        }
        catch (final IOException | SecurityException e)
        {
            LOGGER.warn("Unabled to return root location", e);
            rootLocation = super.getRootLocation();
        }

        return rootLocation;
    }

    /**
     *
     * {@inheritDoc}
     */
    @Override
    public boolean isWriteSupported()
    {
        return !this.readOnly;
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public boolean isContentUrlSupported(final String contentUrl)
    {
        ParameterCheck.mandatoryString("contentUrl", contentUrl);

        // improved check to avoid the implicit, more expensive getReader call performed in super implementation
        // also trims down on logging
        final String effectiveContentUrl = ContentUrlUtils.checkAndReplaceWildcardProtocol(contentUrl, this.protocol);
        final Pair<String, String> urlParts = this.getContentUrlParts(effectiveContentUrl);
        final String protocol = urlParts.getFirst();

        boolean contentUrlSupported;
        if (SPOOF_PROTOCOL.equals(protocol) || this.protocol.equals(protocol))
        {
            LOGGER.debug("Content URL {} with effective protocol {} is supported by store {} with protocol {}", contentUrl, protocol, this,
                    this.protocol);
            contentUrlSupported = true;
        }
        else
        {
            LOGGER.debug("Content URL {} with effective protocol {} is not supported by store {} with protocol {}", contentUrl, protocol,
                    this, this.protocol);
            contentUrlSupported = false;
        }
        return contentUrlSupported;
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public boolean exists(final String contentUrl)
    {
        ParameterCheck.mandatoryString("contentUrl", contentUrl);

        final String effectiveContentUrl = ContentUrlUtils.checkAndReplaceWildcardProtocol(contentUrl, this.protocol);
        final Pair<String, String> urlParts = this.getContentUrlParts(effectiveContentUrl);
        final String protocol = urlParts.getFirst();

        boolean result;
        if (protocol.equals(SPOOF_PROTOCOL))
        {
            result = true;
        }
        else
        {
            final Path filePath = this.makeFilePath(effectiveContentUrl);
            result = Files.exists(filePath) && !Files.isDirectory(filePath);
            LOGGER.debug("Content URL {} {} as a file", contentUrl, result ? "exists" : "does not exist");
        }
        return result;
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public ContentReader getReader(final String contentUrl)
    {
        ParameterCheck.mandatoryString("contentUrl", contentUrl);

        final String effectiveContentUrl = ContentUrlUtils.checkAndReplaceWildcardProtocol(contentUrl, this.protocol);
        final Pair<String, String> urlParts = this.getContentUrlParts(effectiveContentUrl);
        final String protocol = urlParts.getFirst();

        ContentReader reader;
        if (protocol.equals(SPOOF_PROTOCOL))
        {
            reader = new SpoofedTextContentReader(effectiveContentUrl);
        }
        else
        {
            try
            {
                LOGGER.debug("Checking if {} exists as a file to construct a reader", contentUrl);
                final Path filePath = this.makeFilePath(effectiveContentUrl);
                if (Files.exists(filePath) && !Files.isDirectory(filePath))
                {
                    final FileContentReaderImpl fileContentReader = new FileContentReaderImpl(filePath.toFile(), effectiveContentUrl);

                    fileContentReader.setAllowRandomAccess(this.allowRandomAccess);

                    reader = fileContentReader;
                }
                else
                {
                    reader = new EmptyContentReader(effectiveContentUrl);
                }

                LOGGER.debug("Created content reader: \n   url: {}\n   file: {}\n   reader: {}", effectiveContentUrl, filePath, reader);
            }
            catch (final UnsupportedContentUrlException e)
            {
                throw e;
            }
        }
        return reader;
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public boolean delete(final String contentUrl)
    {
        ParameterCheck.mandatoryString("contentUrl", contentUrl);

        if (this.readOnly)
        {
            throw new UnsupportedOperationException("This store is currently read-only: " + this);
        }

        boolean deleted;
        final String effectiveContentUrl = ContentUrlUtils.checkAndReplaceWildcardProtocol(contentUrl, this.protocol);
        final Pair<String, String> urlParts = this.getContentUrlParts(effectiveContentUrl);
        final String protocol = urlParts.getFirst();

        if (protocol.equals(SPOOF_PROTOCOL))
        {
            // This is not a failure but the content can never actually be deleted
            deleted = false;
        }
        else
        {
            LOGGER.debug("Checking if {} exists as a file to be deleted", contentUrl);
            final Path filePath = this.makeFilePath(effectiveContentUrl);
            if (!Files.isRegularFile(filePath))
            {
                LOGGER.debug("Path {} does not denote an existing content file - treating as already deleted", filePath);
                deleted = true;
            }
            else
            {
                // there is no reliable way to check for isDeleteable in advance
                try
                {
                    Files.delete(filePath);
                    deleted = true;
                    LOGGER.debug("Deleted content file {}", filePath);
                }
                catch (final IOException e)
                {
                    LOGGER.warn("Error deleting content file {}", filePath, e);
                    deleted = false;
                }
            }

            if (this.deleteEmptyDirs && deleted)
            {
                this.deleteEmptyParents(filePath, this.rootDirectory);
            }
        }

        return deleted;
    }

    /**
     *
     * {@inheritDoc}
     */
    @Override
    public String toString()
    {
        final StringBuilder sb = new StringBuilder(36);
        sb.append(this.getClass().getSimpleName()).append("[ root=").append(this.rootDirectory).append(", allowRandomAccess=")
                .append(this.allowRandomAccess).append(", readOnly=").append(this.readOnly).append("]");
        return sb.toString();
    }

    /**
     * {@inheritDoc}
     */
    @Override
    protected ContentWriter getWriterInternal(final ContentReader existingContentReader, final String newContentUrl)
    {
        String contentUrl = null;
        try
        {
            if (newContentUrl == null)
            {
                contentUrl = this.createNewFileStoreUrl();
            }
            else
            {
                contentUrl = ContentUrlUtils.checkAndReplaceWildcardProtocol(newContentUrl, this.protocol);
            }

            final File file = this.createNewFile(contentUrl);
            final FileContentWriterImpl writer = new FileContentWriterImpl(file, contentUrl, existingContentReader);

            if (this.contentLimitProvider != null)
            {
                writer.setContentLimitProvider(this.contentLimitProvider);
            }

            writer.setAllowRandomAccess(this.allowRandomAccess);

            LOGGER.debug("Created content writer: \n   writer: {}", writer);
            return writer;
        }
        catch (final Throwable e)
        {
            LOGGER.error("Error creating writer for {}", contentUrl, e);
            throw new ContentIOException("Failed to get writer for URL: " + contentUrl, e);
        }
    }

    /**
     * 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 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)
     */
    protected File createNewFile(final String newContentUrl) throws IOException
    {
        if (this.readOnly)
        {
            throw new UnsupportedOperationException("This store is currently read-only: " + this);
        }

        LOGGER.debug("Creating new file for {}", newContentUrl);
        final Path filePath = this.makeFilePath(newContentUrl);

        if (Files.exists(filePath))
        {
            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);
        }

        final Path parentPath = filePath.getParent();
        // unlikely to be null but possible due to API definition
        if (parentPath != null)
        {
            try
            {
                // ensure to inherit through all folder permissions from root
                final Set<PosixFilePermission> permissions = Files.getPosixFilePermissions(this.rootDirectory.toPath());
                final FileAttribute<Set<PosixFilePermission>> permissionAttribute = PosixFilePermissions.asFileAttribute(permissions);

                Files.createDirectories(parentPath, permissionAttribute);
            }
            catch (final UnsupportedOperationException ex)
            {
                LOGGER.debug(
                        "File system does not support posix file attributes - unable to ensure folder path permissions are consistent with root directory");
                Files.createDirectories(parentPath);
            }
        }
        Files.createFile(filePath);
        LOGGER.debug("Created content file {}", filePath);

        return filePath.toFile();
    }

    /**
     * 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 the equivalent content URL
     */
    // only needed for deprecated getUrls
    @Deprecated
    protected String makeContentUrl(final File file)
    {
        final String path = file.getAbsolutePath();
        if (!path.startsWith(this.rootAbsolutePath))
        {
            throw new AlfrescoRuntimeException(
                    "File does not fall below the store's root: \n" + "   file: " + file + "\n" + "   store: " + this);
        }
        int index = this.rootAbsolutePath.length();
        if (path.charAt(index) == File.separatorChar)
        {
            index++;
        }

        String url = this.protocol + ContentStore.PROTOCOL_DELIMITER + path.substring(index);
        url = url.replace('\\', '/');
        return url;
    }

    /**
     * Creates a file path from the given relative URL.
     *
     * @param contentUrl
     *            the content URL including the protocol prefix
     * @return 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}
     */
    protected Path makeFilePath(final String contentUrl)
    {
        final String baseContentUrl = ContentUrlUtils.getBaseContentUrl(contentUrl);
        final Pair<String, String> urlParts = this.getContentUrlParts(baseContentUrl);
        final String protocol = urlParts.getFirst();
        final String relativePath = urlParts.getSecond();
        return this.makeFilePath(protocol, relativePath);
    }

    /**
     * Creates a file path based on the content URL parts.
     *
     * @param protocol
     *            must be {@link ContentStore#PROTOCOL_DELIMITER} for this class
     * @param relativePath
     *            the relative path to turn into a file
     * @return the file path
     */
    protected Path makeFilePath(final String protocol, final String relativePath)
    {
        if (!StoreConstants.WILDCARD_PROTOCOL.equals(protocol) && !this.protocol.equals(protocol))
        {
            throw new UnsupportedContentUrlException(this, protocol + PROTOCOL_DELIMITER + relativePath);
        }

        final Path rootPath = this.rootDirectory.toPath();
        final Path filePath = rootPath.resolve(relativePath);
        if (!filePath.startsWith(rootPath))
        {
            throw new ContentIOException("Access to files outside of content store root is not allowed: " + filePath);
        }

        return filePath;
    }

    /**
     * Creates a new content URL.
     *
     * @return the new and unique content URL
     */
    protected String createNewFileStoreUrl()
    {
        final String newContentUrl = this.fileContentUrlProvider.createNewFileStoreUrl();
        return newContentUrl;
    }

    protected void deleteEmptyParents(final Path filePath, final File rootDirectory)
    {
        final Path rootDirectoryPath = rootDirectory.toPath();

        Path curPath = filePath.getParent();
        try
        {
            while (curPath != null && Files.exists(curPath) && !Files.isSameFile(rootDirectoryPath, curPath))
            {
                if (Files.isSymbolicLink(curPath))
                {
                    LOGGER.debug("Aborting deletion of empty parents as {} is a symbolic link", curPath);
                    break;
                }

                if (!Files.isDirectory(curPath))
                {
                    LOGGER.debug("Aborting deletion of empty parents as {} is not a directory", curPath);
                    break;
                }

                final long children;
                try (Stream<Path> stream = Files.list(curPath))
                {
                    children = stream.count();
                }

                if (children != 0)
                {
                    LOGGER.debug("Aborting deletion of empty parents as {} is not empty", curPath);
                    break;
                }

                LOGGER.trace("Deleting empty parent {}", curPath);
                Files.delete(curPath);
                LOGGER.debug("Deleted empty parent {}", curPath);
                curPath = curPath.getParent();
            }
        }
        catch (final IOException e)
        {
            LOGGER.warn("Error deleting empty parent directories", e);
        }
    }
}