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

import java.io.BufferedOutputStream;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.channels.Channels;
import java.nio.channels.FileChannel;
import java.nio.channels.ReadableByteChannel;
import java.nio.channels.WritableByteChannel;
import java.nio.charset.Charset;
import java.util.ArrayList;
import java.util.List;

import org.alfresco.api.AlfrescoPublicApi; 
import org.alfresco.error.AlfrescoRuntimeException;
import org.alfresco.repo.content.ContentLimitProvider.NoLimitProvider;
import org.alfresco.repo.content.encoding.ContentCharsetFinder;
import org.alfresco.repo.content.filestore.FileContentWriter;
import org.alfresco.service.cmr.repository.ContentAccessor;
import org.alfresco.service.cmr.repository.ContentIOException;
import org.alfresco.service.cmr.repository.ContentReader;
import org.alfresco.service.cmr.repository.ContentStreamListener;
import org.alfresco.service.cmr.repository.ContentWriter;
import org.alfresco.service.cmr.repository.MimetypeService;
import org.alfresco.service.cmr.repository.MimetypeServiceAware;
import org.alfresco.util.TempFileProvider;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.aop.framework.ProxyFactory;

/**
 * Implements all the convenience methods of the interface.  The only methods
 * that need to be implemented, i.e. provide low-level content access are:
 * <ul>
 *   <li>{@link #getReader()} to create a reader to the underlying content</li>
 *   <li>{@link #getDirectWritableChannel()} to write content to the repository</li>
 * </ul>
 * 
 * @author Derek Hulley
 */
@AlfrescoPublicApi
public abstract class AbstractContentWriter extends AbstractContentAccessor implements ContentWriter, MimetypeServiceAware
{
    private static final Log logger = LogFactory.getLog(AbstractContentWriter.class);
    
    private List<ContentStreamListener> listeners;
    private WritableByteChannel channel;
    private ContentReader existingContentReader;
    private MimetypeService mimetypeService;
    private DoGuessingOnCloseListener guessingOnCloseListener;
    
    /**
     * This object provides a maximum size limit for content.
     * @since Thor
     */
    private ContentLimitProvider limitProvider = new NoLimitProvider();
    private LimitedStreamCopier sizeLimitedStreamCopier = new LimitedStreamCopier();
    
    /**
     * @param contentUrl the content URL
     * @param existingContentReader a reader of a previous version of this content
     */
    protected AbstractContentWriter(String contentUrl, ContentReader existingContentReader)
    {
        super(contentUrl);
        this.existingContentReader = existingContentReader;
        
        listeners = new ArrayList<ContentStreamListener>(2);
        
        // We always register our own listener as the first one
        // This allows us to perform any guessing (if needed) before
        //  the normal listeners kick in and eg write things to the DB
        guessingOnCloseListener = new DoGuessingOnCloseListener();
        listeners.add(guessingOnCloseListener);
    }
    
    public void setContentLimitProvider(ContentLimitProvider limitProvider)
    {
        this.limitProvider = limitProvider;
    }
    
    /**
     * Supplies the Mimetype Service to be used when guessing
     *  encoding and mimetype information. 
     */
    public void setMimetypeService(MimetypeService mimetypeService)
    {
        this.mimetypeService = mimetypeService;
    }

    /**
     * @return Returns a reader onto the previous version of this content
     */
    protected ContentReader getExistingContentReader()
    {
        return existingContentReader;
    }

    /**
     * Adds the listener after checking that the output stream isn't already in
     * use.
     */
    public synchronized void addListener(ContentStreamListener listener)
    {
        if (channel != null)
        {
            throw new RuntimeException("Channel is already in use");
        }
        listeners.add(listener);
    }

    /**
     * A factory method for subclasses to implement that will ensure the proper
     * implementation of the {@link ContentWriter#getReader()} method.
     * <p>
     * Only the instance need be constructed.  The required mimetype, encoding, etc
     * will be copied across by this class.
     * <p>
     *  
     * @return Returns a reader onto the location referenced by this instance.
     *      The instance must <b>always</b> be a new instance and never null.
     * @throws ContentIOException
     */
    protected abstract ContentReader createReader() throws ContentIOException;
    
    /**
     * Performs checks and copies required reader attributes
     */
    public final ContentReader getReader() throws ContentIOException
    {
        String contentUrl = getContentUrl();
        if (!isClosed())
        {
            return new EmptyContentReader(contentUrl);
        }
        ContentReader reader = createReader();
        if (reader == null)
        {
            throw new AlfrescoRuntimeException("ContentReader failed to create new reader: \n" +
                    "   writer: " + this);
        }
        else if (reader.getContentUrl() == null || !reader.getContentUrl().equals(contentUrl))
        {
            throw new AlfrescoRuntimeException("ContentReader has different URL: \n" +
                    "   writer: " + this + "\n" +
                    "   new reader: " + reader);
        }
        // copy across common attributes
        reader.setMimetype(this.getMimetype());
        reader.setEncoding(this.getEncoding());
        reader.setLocale(this.getLocale());
        // done
        if (logger.isDebugEnabled())
        {
            logger.debug("Writer spawned new reader: \n" +
                    "   writer: " + this + "\n" +
                    "   new reader: " + reader);
        }
        return reader;
    }
    
    /**
     * This method returns the configured {@link ContentLimitProvider} for this writer.
     * By default a {@link NoLimitProvider} will be returned.
     * @since Thor
     */
    protected ContentLimitProvider getContentLimitProvider()
    {
        return this.limitProvider == null ? new NoLimitProvider() : this.limitProvider;
    }

    /**
     * An automatically created listener sets the flag
     */
    public synchronized final boolean isClosed()
    {
        if (channel != null)
        {
            return !channel.isOpen();
        }
        else
        {
            return false;
        }
    }

    public synchronized boolean isChannelOpen()
    {
        if (channel != null)
        {
            return channel.isOpen();
        }
        else
        {
            return false;
        }
    }
    
    /**
     * Provides low-level access to write content to the repository.
     * <p>
     * This is the only of the content <i>writing</i> methods that needs to be implemented
     * by derived classes.  All other content access methods make use of this in their
     * underlying implementations.
     * 
     * @return Returns a channel with which to write content
     * @throws ContentIOException if the channel could not be opened
     */
    protected abstract WritableByteChannel getDirectWritableChannel() throws ContentIOException;
    
    /**
     * Create a channel that performs callbacks to the given listeners.
     *  
     * @param directChannel the result of {@link #getDirectWritableChannel()}
     * @param listeners the listeners to call
     * @return Returns a channel that executes callbacks
     * @throws ContentIOException
     */
    private WritableByteChannel getCallbackWritableChannel(
            WritableByteChannel directChannel,
            List<ContentStreamListener> listeners)
            throws ContentIOException
    {
        WritableByteChannel callbackChannel = null;
        if (directChannel instanceof FileChannel)
        {
            callbackChannel = getCallbackFileChannel((FileChannel) directChannel, listeners);
        }
        else
        {
            // introduce an advistor to handle the callbacks to the listeners
            ChannelCloseCallbackAdvise advise = new ChannelCloseCallbackAdvise(listeners);
            ProxyFactory proxyFactory = new ProxyFactory(directChannel);
            proxyFactory.addAdvice(advise);
            callbackChannel = (WritableByteChannel) proxyFactory.getProxy();
        }
        // done
        if (logger.isDebugEnabled())
        {
            logger.debug("Created callback byte channel: \n" +
                    "   original: " + directChannel + "\n" +
                    "   new: " + callbackChannel);
        }
        return callbackChannel;
    }

    /**
     * @see #getDirectWritableChannel()
     * @see #getCallbackWritableChannel(java.nio.channels.WritableByteChannel, List)
     */
    public synchronized final WritableByteChannel getWritableChannel() throws ContentIOException
    {
        // this is a use-once object
        if (channel != null)
        {
            throw new ContentIOException("A channel has already been opened");
        }
        WritableByteChannel directChannel = getDirectWritableChannel();
        channel = getCallbackWritableChannel(directChannel, listeners);

        // notify that the channel was opened
        super.channelOpened();
        // done
        if (logger.isDebugEnabled())
        {
            logger.debug("Opened channel onto content: \n" +
                    "   content: " + this + "\n" +
                    "   channel: " + channel);
        }
        return channel;
    }
    
    /**
     * {@inheritDoc}
     */
    public FileChannel getFileChannel(boolean truncate) throws ContentIOException
    {
        /*
         * By calling this method, clients indicate that they wish to make random
         * changes to the file.  It is possible that the client might only want
         * to update a tiny proportion of the file (truncate == false) or
         * start afresh (truncate == true).
         * 
         * Where the underlying support is not present for this method, a temporary
         * file will be used as a substitute.  When the write is complete, the
         * results are copied directly to the underlying channel.
         */
        
        // get the underlying implementation's best writable channel
        channel = getWritableChannel();
        // now use this channel if it can provide the random access, otherwise spoof it
        FileChannel clientFileChannel = null;
        if (channel instanceof FileChannel)
        {
            // all the support is provided by the underlying implementation
            clientFileChannel = (FileChannel) channel;
            // copy over the existing content, if required
            if (!truncate && existingContentReader != null)
            {
                ReadableByteChannel existingContentChannel = existingContentReader.getReadableChannel();
                long existingContentLength = existingContentReader.getSize();
                // copy the existing content
                try
                {
                    clientFileChannel.transferFrom(existingContentChannel, 0, existingContentLength);
                    // copy complete
                    if (logger.isDebugEnabled())
                    {
                        logger.debug("Copied content for random access: \n" +
                                "   writer: " + this + "\n" +
                                "   existing: " + existingContentReader);
                    }
                }
                catch (IOException e)
                {
                    throw new ContentIOException("Failed to copy from existing content to enable random access: \n" +
                            "   writer: " + this + "\n" +
                            "   existing: " + existingContentReader,
                            e);
                }
                finally
                {
                    try { existingContentChannel.close(); } catch (IOException e) {}
                }
            }
            // debug
            if (logger.isDebugEnabled())
            {
                logger.debug("Content writer provided direct support for FileChannel: \n" +
                        "   writer: " + this);
            }
        }
        else
        {
            // No random access support is provided by the implementation.
            // Spoof it by providing a 2-stage write via a temp file
            File tempFile = TempFileProvider.createTempFile("random_write_spoof_", ".bin");
            final FileContentWriter spoofWriter = new FileContentWriter(
                    tempFile,                           // the file to write to
                    getExistingContentReader());        // this ensures that the existing content is pulled in
            // Attach a listener
            // - to ensure that the content gets loaded from the temp file once writing has finished
            // - to ensure that the close call gets passed on to the underlying channel
            ContentStreamListener spoofListener = new ContentStreamListener()
            {
                public void contentStreamClosed() throws ContentIOException
                {
                    // the spoofed temp channel has been closed, so get a new reader for it
                    ContentReader spoofReader = spoofWriter.getReader();
                    FileChannel spoofChannel = spoofReader.getFileChannel();
                    // upload all the temp content to the real underlying channel
                    try
                    {
                        long spoofFileSize = spoofChannel.size();
                        spoofChannel.transferTo(0, spoofFileSize, channel);
                    }
                    catch (IOException e)
                    {
                        throw new ContentIOException("Failed to copy from spoofed temporary channel to permanent channel: \n" +
                                "   writer: " + this + "\n" +
                                "   temp: " + spoofReader,
                                e);
                    }
                    finally
                    {
                        try { spoofChannel.close(); } catch (Throwable e) {}
                        try
                        {
                            channel.close();
                        }
                        catch (IOException e)
                        {
                            throw new ContentIOException("Failed to close underlying channel", e);
                        }
                    }
                }
            };
            spoofWriter.addListener(spoofListener);
            // we now have the spoofed up channel that the client can work with
            clientFileChannel = spoofWriter.getFileChannel(truncate);
            // debug
            if (logger.isDebugEnabled())
            {
                logger.debug("Content writer provided indirect support for FileChannel: \n" +
                        "   writer: " + this + "\n" +
                        "   temp writer: " + spoofWriter);
            }
        }
        // the file is now available for random access
        return clientFileChannel;
    }

    /**
     * @see Channels#newOutputStream(java.nio.channels.WritableByteChannel)
     */
    public OutputStream getContentOutputStream() throws ContentIOException
    {
        try
        {
            WritableByteChannel channel = getWritableChannel();
            OutputStream is = new BufferedOutputStream(Channels.newOutputStream(channel));
            // done
            return is;
        }
        catch (Throwable e)
        {
            throw new ContentIOException("Failed to open stream onto channel: \n" +
                    "   writer: " + this,
                    e);
        }
    }

    /**
     * @see ContentReader#getContentInputStream()
     * @see #putContent(InputStream) 
     */
    public void putContent(ContentReader reader) throws ContentIOException
    {
        try
        {
            // get the stream to read from
            InputStream is = reader.getContentInputStream();
            // put the content
            putContent(is);
        }
        catch (Throwable e)
        {
            throw new ContentIOException("Failed to copy reader content to writer: \n" +
                    "   writer: " + this + "\n" +
                    "   source reader: " + reader,
                    e);
        }
    }

    public final void putContent(InputStream is) throws ContentIOException
    {
        try
        {
            OutputStream os = getContentOutputStream();
            copyStreams(is, os);     // both streams are closed
            // done
        }
        catch (IOException e)
        {
            throw new ContentIOException("Failed to copy content from input stream: \n" +
                    "   writer: " + this,
                    e);
        }
    }
    
    public final void putContent(File file) throws ContentIOException
    {
        try
        {
            OutputStream os = getContentOutputStream();
            FileInputStream is = new FileInputStream(file);
            copyStreams(is, os);     // both streams are closed
            // done
        }
        catch (IOException e)
        {
            throw new ContentIOException("Failed to copy content from file: \n" +
                    "   writer: " + this + "\n" +
                    "   file: " + file,
                    e);
        }
    }
    
    /**
     * Copy of the the Spring FileCopyUtils, but does not silently absorb IOExceptions
     * when the streams are closed.  We require the stream write to happen successfully.
     * <p/>
     * Both streams are closed but any IOExceptions are thrown
     */
    private final long copyStreams(InputStream in, OutputStream out) throws IOException
    {
        ContentLimitProvider contentLimitProvider = getContentLimitProvider();
        final long sizeLimit = contentLimitProvider.getSizeLimit();
        
        long byteCount = sizeLimitedStreamCopier.copyStreamsLong(in, out, sizeLimit);
        return byteCount;
    }
    
    /**
     * Makes use of the encoding, if available, to convert the string to bytes.
     * 
     * @see ContentAccessor#getEncoding()
     */
    public final void putContent(String content) throws ContentIOException
    {
        try
        {
            // attempt to use the correct encoding
            String encoding = getEncoding();
            byte[] bytes;
            if(encoding == null) 
            {
                // Use the system default, and record what that was
                bytes = content.getBytes();
                setEncoding( System.getProperty("file.encoding") );
            }
            else
            {
                // Use the encoding that they specified
                bytes = content.getBytes(encoding);
            }

            // get the stream
            OutputStream os = getContentOutputStream();
            ByteArrayInputStream is = new ByteArrayInputStream(bytes);
            copyStreams(is, os);     // both streams are closed
            // done
        }
        catch (IOException e)
        {
            throw new ContentIOException("Failed to copy content from string: \n" +
                    "   writer: " + this +
                    "   content length: " + content.length(),
                    e);
        }
    }
    
    /**
     * When the content has been written, attempt to guess
     *  the encoding of it.
     *  
     * @see ContentWriter#guessEncoding()
     */
    public void guessEncoding()
    {
        if (mimetypeService == null)
        {
            logger.warn("MimetypeService not supplied, but required for content guessing");
            return;
        }
        
        if(isClosed())
        {
            // Content written, can do it now
            doGuessEncoding();
        }
        else
        {
            // Content not yet written, wait for the
            //  data to be written before doing so
            guessingOnCloseListener.guessEncoding = true;
        }
    }
    private void doGuessEncoding()
    {
        ContentCharsetFinder charsetFinder = mimetypeService.getContentCharsetFinder();
        
        ContentReader reader = getReader();
        InputStream is = reader.getContentInputStream();
        Charset charset = charsetFinder.getCharset(is, getMimetype());
        try
        {
            is.close();
        }
        catch(IOException e)
        {}
        
        setEncoding(charset.name());
    }

    /**
     * When the content has been written, attempt to guess
     *  the mimetype of it, using the filename and contents.
     *  
     * @see ContentWriter#guessMimetype(String)
     */
    public void guessMimetype(String filename)
    {
        if (mimetypeService == null)
        {
            logger.warn("MimetypeService not supplied, but required for content guessing");
            return;
        }
        
        
        if(isClosed())
        {
            // Content written, can do it now
            doGuessMimetype(filename);
        }
        else
        {
            // Content not yet written, wait for the
            //  data to be written before doing so
            guessingOnCloseListener.guessMimetype = true;
            guessingOnCloseListener.filename = filename;
        }
    }

    private void doGuessMimetype(String filename)
    {
        String mimetype;
        if (filename != null && filename.startsWith(MimetypeMap.MACOS_RESOURCE_FORK_FILE_NAME_PREFIX))
        {
            mimetype = MimetypeMap.MIMETYPE_APPLEFILE;
        }
        else
        {
            mimetype = mimetypeService.guessMimetype(filename, getReader());
        }
        setMimetype(mimetype);
    }
    
    /**
     * Our own listener that is always the first on the list,
     *  which lets us perform guessing operations when the
     *  content has been written.
     */
    private class DoGuessingOnCloseListener implements ContentStreamListener
    {
        private boolean guessEncoding = false;
        private boolean guessMimetype = false;
        private String filename = null;

        @Override
        public void contentStreamClosed() throws ContentIOException
        {
            if(guessMimetype)
            {
                doGuessMimetype(filename);
            }
            if(guessEncoding)
            {
                doGuessEncoding();
            }
        }
    }
}
