/**
 * 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.camel.converter.stream;

import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
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.WritableByteChannel;
import java.security.GeneralSecurityException;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;

import javax.crypto.CipherInputStream;
import javax.crypto.CipherOutputStream;

import org.apache.camel.Exchange;
import org.apache.camel.RuntimeCamelException;
import org.apache.camel.StreamCache;
import org.apache.camel.spi.StreamCachingStrategy;
import org.apache.camel.spi.Synchronization;
import org.apache.camel.spi.UnitOfWork;
import org.apache.camel.support.SynchronizationAdapter;
import org.apache.camel.util.FileUtil;
import org.apache.camel.util.IOHelper;
import org.apache.camel.util.ObjectHelper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * A {@link StreamCache} for {@link File}s
 */
public final class FileInputStreamCache extends InputStream implements StreamCache {
    private InputStream stream;
    private final long length;
    private final FileInputStreamCache.TempFileManager tempFileManager;
    private final File file;
    private final CipherPair ciphers;

    /** Only for testing purposes.*/
    public FileInputStreamCache(File file) throws FileNotFoundException {
        this(new TempFileManager(file, true));
    }
    
    FileInputStreamCache(TempFileManager closer) throws FileNotFoundException {
        this.file = closer.getTempFile();
        this.stream = null;
        this.ciphers = closer.getCiphers();
        this.length = file.length();
        this.tempFileManager = closer;
        this.tempFileManager.add(this);
    }
    
    @Override
    public void close() {
        if (stream != null) {
            IOHelper.close(stream);
        }
    }

    @Override
    public void reset() {
        // reset by closing and creating a new stream based on the file
        close();
        // reset by creating a new stream based on the file
        stream = null;
        
        if (!file.exists()) {
            throw new RuntimeCamelException("Cannot reset stream from file " + file);
        }
    }

    public void writeTo(OutputStream os) throws IOException {
        if (stream == null && ciphers == null) {
            FileInputStream s = new FileInputStream(file);
            long len = file.length();
            WritableByteChannel out;
            if (os instanceof WritableByteChannel) {
                out = (WritableByteChannel)os;
            } else {
                out = Channels.newChannel(os);
            }
            FileChannel fc = s.getChannel();
            long pos = 0;
            while (pos < len) {
                long i = fc.transferTo(pos, len - pos, out);
                pos += i;
            }
            s.close();
            fc.close();
        } else {
            IOHelper.copy(getInputStream(), os);
        }
    }

    public StreamCache copy(Exchange exchange) throws IOException {
        tempFileManager.addExchange(exchange);
        FileInputStreamCache copy = new FileInputStreamCache(tempFileManager);
        return copy;
    }

    public boolean inMemory() {
        return false;
    }

    public long length() {
        return length;
    }

    @Override
    public int available() throws IOException {
        return getInputStream().available();
    }

    @Override
    public int read() throws IOException {
        return getInputStream().read();
    }

    protected InputStream getInputStream() throws IOException {
        if (stream == null) {
            stream = createInputStream(file);
        }
        return stream;
    }

    private InputStream createInputStream(File file) throws IOException {
        InputStream in = new BufferedInputStream(new FileInputStream(file));
        if (ciphers != null) {
            in = new CipherInputStream(in, ciphers.getDecryptor()) {
                boolean closed;
                public void close() throws IOException {
                    if (!closed) {
                        super.close();
                        closed = true;
                    }
                }
            };
        }
        return in;
    }

    /** 
     * Manages the temporary file for the file input stream caches.
     * 
     * Collects all FileInputStreamCache instances of the temporary file.
     * Counts the number of exchanges which have a FileInputStreamCache  instance of the temporary file.
     * Deletes the temporary file, if all exchanges are done.
     * 
     * @see CachedOutputStream
     */
    static class TempFileManager {
        
        private static final Logger LOG = LoggerFactory.getLogger(TempFileManager.class);
        /** Indicator whether the file input stream caches are closed on completion of the exchanges. */
        private final boolean closedOnCompletion;
        private AtomicInteger exchangeCounter = new AtomicInteger();
        private File tempFile;
        private OutputStream outputStream; // file output stream
        private CipherPair ciphers;
        
        // there can be several input streams, for example in the multi-cast, or wiretap parallel processing
        private List<FileInputStreamCache> fileInputStreamCaches;

        /** Only for testing.*/
        private TempFileManager(File file, boolean closedOnCompletion) {
            this(closedOnCompletion);
            this.tempFile = file;
        }
        
        TempFileManager(boolean closedOnCompletion) {
            this.closedOnCompletion = closedOnCompletion;
        }
                
        /** Adds a FileInputStreamCache instance to the closer.
         * <p>
         * Must be synchronized, because can be accessed by several threads. 
         */
        synchronized void add(FileInputStreamCache fileInputStreamCache) {
            if (fileInputStreamCaches == null) {
                fileInputStreamCaches = new ArrayList<FileInputStreamCache>(3);
            }
            fileInputStreamCaches.add(fileInputStreamCache);
        }
        
        void addExchange(Exchange exchange) {
            if (closedOnCompletion) {
                exchangeCounter.incrementAndGet();
                // add on completion so we can cleanup after the exchange is done such as deleting temporary files
                Synchronization onCompletion = new SynchronizationAdapter() {
                    @Override
                    public void onDone(Exchange exchange) {
                        int actualExchanges = exchangeCounter.decrementAndGet();
                        if (actualExchanges == 0) {
                            // only one exchange (one thread) left, therefore we must not synchronize the following lines of code
                            try {                              
                                closeFileInputStreams();
                                if (outputStream != null) {
                                    outputStream.close();
                                }
                                try {
                                    cleanUpTempFile();
                                } catch (Exception e) {
                                    LOG.warn("Error deleting temporary cache file: " + tempFile + ". This exception will be ignored.", e);
                                }
                            } catch (Exception e) {
                                LOG.warn("Error closing streams. This exception will be ignored.", e);
                            }
                        }
                    }

                    @Override
                    public String toString() {
                        return "OnCompletion[CachedOutputStream]";
                    }
                };
                UnitOfWork streamCacheUnitOfWork = exchange.getProperty(Exchange.STREAM_CACHE_UNIT_OF_WORK, UnitOfWork.class);
                if (streamCacheUnitOfWork != null) {
                    // The stream cache must sometimes not be closed when the exchange is deleted. This is for example the
                    // case in the splitter and multi-cast case with AggregationStrategy where the result of the sub-routes
                    // are aggregated later in the main route. Here, the cached streams of the sub-routes must be closed with
                    // the Unit of Work of the main route.
                    streamCacheUnitOfWork.addSynchronization(onCompletion);
                } else {
                    // add on completion so we can cleanup after the exchange is done such as deleting temporary files
                    exchange.addOnCompletion(onCompletion);
                }
            }
        }
        
        OutputStream createOutputStream(StreamCachingStrategy strategy) throws IOException {
            // should only be called once
            if (tempFile != null) {
                throw new IllegalStateException("The method 'createOutputStream' can only be called once!");
            }
            tempFile = FileUtil.createTempFile("cos", ".tmp", strategy.getSpoolDirectory());

            LOG.trace("Creating temporary stream cache file: {}", tempFile);
            OutputStream out = new BufferedOutputStream(new FileOutputStream(tempFile));
            if (ObjectHelper.isNotEmpty(strategy.getSpoolChiper())) {
                try {
                    if (ciphers == null) {
                        ciphers = new CipherPair(strategy.getSpoolChiper());
                    }
                } catch (GeneralSecurityException e) {
                    throw new IOException(e.getMessage(), e);
                }
                out = new CipherOutputStream(out, ciphers.getEncryptor()) {
                    boolean closed;
                    public void close() throws IOException {
                        if (!closed) {
                            super.close();
                            closed = true;
                        }
                    }
                };
            }
            outputStream = out;
            return out;
        }
        
        FileInputStreamCache newStreamCache() throws IOException {
            try {
                return new FileInputStreamCache(this);
            } catch (FileNotFoundException e) {
                throw new IOException("Cached file " + tempFile + " not found", e);
            }
        }
        
        void closeFileInputStreams() {
            if (fileInputStreamCaches != null) {
                for (FileInputStreamCache fileInputStreamCache : fileInputStreamCaches) {
                    fileInputStreamCache.close();
                }
                fileInputStreamCaches.clear();
            }
        } 

        void cleanUpTempFile() {
            // cleanup temporary file
            try {
                if (tempFile != null) {
                    FileUtil.deleteFile(tempFile);
                    tempFile = null;
                }
            } catch (Exception e) {
                LOG.warn("Error deleting temporary cache file: " + tempFile + ". This exception will be ignored.", e);
            }
        }
        
        File getTempFile() {
            return tempFile;
        }
        
        CipherPair getCiphers() {
            return ciphers;
        }
        
    }

}
