/**
 * 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.component.file;

import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.RandomAccessFile;
import java.io.Reader;
import java.io.Writer;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.file.Files;
import java.nio.file.attribute.PosixFilePermission;
import java.nio.file.attribute.PosixFilePermissions;
import java.util.Date;
import java.util.List;
import java.util.Set;

import org.apache.camel.Exchange;
import org.apache.camel.InvalidPayloadException;
import org.apache.camel.WrappedFile;
import org.apache.camel.converter.IOConverter;
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;

/**
 * File operations for {@link java.io.File}.
 */
public class FileOperations implements GenericFileOperations<File> {
    private static final Logger LOG = LoggerFactory.getLogger(FileOperations.class);
    private FileEndpoint endpoint;

    public FileOperations() {
    }

    public FileOperations(FileEndpoint endpoint) {
        this.endpoint = endpoint;
    }

    public void setEndpoint(GenericFileEndpoint<File> endpoint) {
        this.endpoint = (FileEndpoint) endpoint;
    }

    public boolean deleteFile(String name) throws GenericFileOperationFailedException {
        File file = new File(name);
        return FileUtil.deleteFile(file);
    }

    public boolean renameFile(String from, String to) throws GenericFileOperationFailedException {
        boolean renamed = false;
        File file = new File(from);
        File target = new File(to);
        try {
            if (endpoint.isRenameUsingCopy()) {
                renamed = FileUtil.renameFileUsingCopy(file, target);
            } else {
                renamed = FileUtil.renameFile(file, target, endpoint.isCopyAndDeleteOnRenameFail());
            }
        } catch (IOException e) {
            throw new GenericFileOperationFailedException("Error renaming file from " + from + " to " + to, e);
        }
        
        return renamed;
    }

    public boolean existsFile(String name) throws GenericFileOperationFailedException {
        File file = new File(name);
        return file.exists();
    }

    protected boolean buildDirectory(File dir, Set<PosixFilePermission> permissions) {
        if (dir.exists()) {
            return true;
        }

        if (permissions == null || permissions.isEmpty()) {
            return dir.mkdirs();
        }

        // create directory one part of a time and set permissions
        try {
            String[] parts = dir.getPath().split("\\" + File.separatorChar);
            File base = new File(".");
            for (String part : parts) {
                File subDir = new File(base, part);
                if (!subDir.exists()) {
                    if (subDir.mkdir()) {
                        if (LOG.isTraceEnabled()) {
                            LOG.trace("Setting chmod: {} on directory: {} ", PosixFilePermissions.toString(permissions), subDir);
                        }
                        Files.setPosixFilePermissions(subDir.toPath(), permissions);
                    } else {
                        return false;
                    }
                }
                base = new File(base, subDir.getName());
            }
        } catch (IOException e) {
            throw new GenericFileOperationFailedException("Error setting chmod on directory: " + dir, e);
        }

        return true;
    }

    public boolean buildDirectory(String directory, boolean absolute) throws GenericFileOperationFailedException {
        ObjectHelper.notNull(endpoint, "endpoint");

        // always create endpoint defined directory
        if (endpoint.isAutoCreate() && !endpoint.getFile().exists()) {
            LOG.trace("Building starting directory: {}", endpoint.getFile());
            buildDirectory(endpoint.getFile(), endpoint.getDirectoryPermissions());
        }

        if (ObjectHelper.isEmpty(directory)) {
            // no directory to build so return true to indicate ok
            return true;
        }

        File endpointPath = endpoint.getFile();
        File target = new File(directory);

        File path;
        if (absolute) {
            // absolute path
            path = target;
        } else if (endpointPath.equals(target)) {
            // its just the root of the endpoint path
            path = endpointPath;
        } else {
            // relative after the endpoint path
            String afterRoot = ObjectHelper.after(directory, endpointPath.getPath() + File.separator);
            if (ObjectHelper.isNotEmpty(afterRoot)) {
                // dir is under the root path
                path = new File(endpoint.getFile(), afterRoot);
            } else {
                // dir is relative to the root path
                path = new File(endpoint.getFile(), directory);
            }
        }

        // We need to make sure that this is thread-safe and only one thread tries to create the path directory at the same time.
        synchronized (this) {
            if (path.isDirectory() && path.exists()) {
                // the directory already exists
                return true;
            } else {
                LOG.trace("Building directory: {}", path);
                return buildDirectory(path, endpoint.getDirectoryPermissions());
            }
        }
    }

    public List<File> listFiles() throws GenericFileOperationFailedException {
        // noop
        return null;
    }

    public List<File> listFiles(String path) throws GenericFileOperationFailedException {
        // noop
        return null;
    }

    public void changeCurrentDirectory(String path) throws GenericFileOperationFailedException {
        // noop
    }

    public void changeToParentDirectory() throws GenericFileOperationFailedException {
        // noop
    }

    public String getCurrentDirectory() throws GenericFileOperationFailedException {
        // noop
        return null;
    }

    public boolean retrieveFile(String name, Exchange exchange) throws GenericFileOperationFailedException {
        // noop as we use type converters to read the body content for java.io.File
        return true;
    }
    
    @Override
    public void releaseRetreivedFileResources(Exchange exchange) throws GenericFileOperationFailedException {
        // noop as we used type converters to read the body content for java.io.File
    }

    public boolean storeFile(String fileName, Exchange exchange) throws GenericFileOperationFailedException {
        ObjectHelper.notNull(endpoint, "endpoint");

        File file = new File(fileName);

        // if an existing file already exists what should we do?
        if (file.exists()) {
            if (endpoint.getFileExist() == GenericFileExist.Ignore) {
                // ignore but indicate that the file was written
                LOG.trace("An existing file already exists: {}. Ignore and do not override it.", file);
                return true;
            } else if (endpoint.getFileExist() == GenericFileExist.Fail) {
                throw new GenericFileOperationFailedException("File already exist: " + file + ". Cannot write new file.");
            } else if (endpoint.getFileExist() == GenericFileExist.Move) {
                // move any existing file first
                doMoveExistingFile(fileName);
            }
        }
        
        // Do an explicit test for a null body and decide what to do
        if (exchange.getIn().getBody() == null) {
            if (endpoint.isAllowNullBody()) {
                LOG.trace("Writing empty file.");
                try {
                    writeFileEmptyBody(file);
                    return true;
                } catch (IOException e) {
                    throw new GenericFileOperationFailedException("Cannot store file: " + file, e);
                }
            } else {
                throw new GenericFileOperationFailedException("Cannot write null body to file: " + file);
            }
        }

        // we can write the file by 3 different techniques
        // 1. write file to file
        // 2. rename a file from a local work path
        // 3. write stream to file
        try {

            // is there an explicit charset configured we must write the file as
            String charset = endpoint.getCharset();

            // we can optimize and use file based if no charset must be used, and the input body is a file
            File source = null;
            boolean fileBased = false;
            if (charset == null) {
                // if no charset, then we can try using file directly (optimized)
                Object body = exchange.getIn().getBody();
                if (body instanceof WrappedFile) {
                    body = ((WrappedFile<?>) body).getFile();
                }
                if (body instanceof File) {
                    source = (File) body;
                    fileBased = true;
                }
            }

            if (fileBased) {
                // okay we know the body is a file based

                // so try to see if we can optimize by renaming the local work path file instead of doing
                // a full file to file copy, as the local work copy is to be deleted afterwards anyway
                // local work path
                File local = exchange.getIn().getHeader(Exchange.FILE_LOCAL_WORK_PATH, File.class);
                if (local != null && local.exists()) {
                    boolean renamed = writeFileByLocalWorkPath(local, file);
                    if (renamed) {
                        // try to keep last modified timestamp if configured to do so
                        keepLastModified(exchange, file);
                        // set permissions if the chmod option was set
                        if (ObjectHelper.isNotEmpty(endpoint.getChmod())) {
                            Set<PosixFilePermission> permissions = endpoint.getPermissions();
                            if (!permissions.isEmpty()) {
                                if (LOG.isTraceEnabled()) {
                                    LOG.trace("Setting chmod: {} on file: {} ", PosixFilePermissions.toString(permissions), file);
                                }
                                Files.setPosixFilePermissions(file.toPath(), permissions);
                            }
                        }
                        // clear header as we have renamed the file
                        exchange.getIn().setHeader(Exchange.FILE_LOCAL_WORK_PATH, null);
                        // return as the operation is complete, we just renamed the local work file
                        // to the target.
                        return true;
                    }
                } else if (source != null && source.exists()) {
                    // no there is no local work file so use file to file copy if the source exists
                    writeFileByFile(source, file);
                    // try to keep last modified timestamp if configured to do so
                    keepLastModified(exchange, file);
                    // set permissions if the chmod option was set
                    if (ObjectHelper.isNotEmpty(endpoint.getChmod())) {
                        Set<PosixFilePermission> permissions = endpoint.getPermissions();
                        if (!permissions.isEmpty()) {
                            if (LOG.isTraceEnabled()) {
                                LOG.trace("Setting chmod: {} on file: {} ", PosixFilePermissions.toString(permissions), file);
                            }
                            Files.setPosixFilePermissions(file.toPath(), permissions);
                        }
                    }
                    return true;
                }
            }

            if (charset != null) {
                // charset configured so we must use a reader so we can write with encoding
                Reader in = exchange.getContext().getTypeConverter().tryConvertTo(Reader.class, exchange, exchange.getIn().getBody());
                if (in == null) {
                    // okay no direct reader conversion, so use an input stream (which a lot can be converted as)
                    InputStream is = exchange.getIn().getMandatoryBody(InputStream.class);
                    in = new InputStreamReader(is);
                }
                // buffer the reader
                in = IOHelper.buffered(in);
                writeFileByReaderWithCharset(in, file, charset);
            } else {
                // fallback and use stream based
                InputStream in = exchange.getIn().getMandatoryBody(InputStream.class);
                writeFileByStream(in, file);
            }

            // try to keep last modified timestamp if configured to do so
            keepLastModified(exchange, file);
            // set permissions if the chmod option was set
            if (ObjectHelper.isNotEmpty(endpoint.getChmod())) {
                Set<PosixFilePermission> permissions = endpoint.getPermissions();
                if (!permissions.isEmpty()) {
                    if (LOG.isTraceEnabled()) {
                        LOG.trace("Setting chmod: {} on file: {} ", PosixFilePermissions.toString(permissions), file);
                    }
                    Files.setPosixFilePermissions(file.toPath(), permissions);
                }
            }

            return true;
        } catch (IOException e) {
            throw new GenericFileOperationFailedException("Cannot store file: " + file, e);
        } catch (InvalidPayloadException e) {
            throw new GenericFileOperationFailedException("Cannot store file: " + file, e);
        }
    }

    /**
     * Moves any existing file due fileExists=Move is in use.
     */
    private void doMoveExistingFile(String fileName) throws GenericFileOperationFailedException {
        // need to evaluate using a dummy and simulate the file first, to have access to all the file attributes
        // create a dummy exchange as Exchange is needed for expression evaluation
        // we support only the following 3 tokens.
        Exchange dummy = endpoint.createExchange();
        String parent = FileUtil.onlyPath(fileName);
        String onlyName = FileUtil.stripPath(fileName);
        dummy.getIn().setHeader(Exchange.FILE_NAME, fileName);
        dummy.getIn().setHeader(Exchange.FILE_NAME_ONLY, onlyName);
        dummy.getIn().setHeader(Exchange.FILE_PARENT, parent);

        String to = endpoint.getMoveExisting().evaluate(dummy, String.class);
        // we must normalize it (to avoid having both \ and / in the name which confuses java.io.File)
        to = FileUtil.normalizePath(to);
        if (ObjectHelper.isEmpty(to)) {
            throw new GenericFileOperationFailedException("moveExisting evaluated as empty String, cannot move existing file: " + fileName);
        }

        // ensure any paths is created before we rename as the renamed file may be in a different path (which may be non exiting)
        // use java.io.File to compute the file path
        File toFile = new File(to);
        String directory = toFile.getParent();
        boolean absolute = FileUtil.isAbsolute(toFile);
        if (directory != null) {
            if (!buildDirectory(directory, absolute)) {
                LOG.debug("Cannot build directory [{}] (could be because of denied permissions)", directory);
            }
        }

        // deal if there already exists a file
        if (existsFile(to)) {
            if (endpoint.isEagerDeleteTargetFile()) {
                LOG.trace("Deleting existing file: {}", to);
                if (!deleteFile(to)) {
                    throw new GenericFileOperationFailedException("Cannot delete file: " + to);
                }
            } else {
                throw new GenericFileOperationFailedException("Cannot moved existing file from: " + fileName + " to: " + to + " as there already exists a file: " + to);
            }
        }

        LOG.trace("Moving existing file: {} to: {}", fileName, to);
        if (!renameFile(fileName, to)) {
            throw new GenericFileOperationFailedException("Cannot rename file from: " + fileName + " to: " + to);
        }
    }

    private void keepLastModified(Exchange exchange, File file) {
        if (endpoint.isKeepLastModified()) {
            Long last;
            Date date = exchange.getIn().getHeader(Exchange.FILE_LAST_MODIFIED, Date.class);
            if (date != null) {
                last = date.getTime();
            } else {
                // fallback and try a long
                last = exchange.getIn().getHeader(Exchange.FILE_LAST_MODIFIED, Long.class);
            }
            if (last != null) {
                boolean result = file.setLastModified(last);
                if (LOG.isTraceEnabled()) {
                    LOG.trace("Keeping last modified timestamp: {} on file: {} with result: {}", new Object[]{last, file, result});
                }
            }
        }
    }

    private boolean writeFileByLocalWorkPath(File source, File file) throws IOException {
        LOG.trace("Using local work file being renamed from: {} to: {}", source, file);
        return FileUtil.renameFile(source, file, endpoint.isCopyAndDeleteOnRenameFail());
    }

    private void writeFileByFile(File source, File target) throws IOException {
        FileChannel in = new FileInputStream(source).getChannel();
        FileChannel out = null;
        try {
            out = prepareOutputFileChannel(target);
            LOG.debug("Using FileChannel to write file: {}", target);
            long size = in.size();
            long position = 0;
            while (position < size) {
                position += in.transferTo(position, endpoint.getBufferSize(), out);
            }
        } finally {
            IOHelper.close(in, source.getName(), LOG);
            IOHelper.close(out, target.getName(), LOG, endpoint.isForceWrites());
        }
    }

    private void writeFileByStream(InputStream in, File target) throws IOException {
        FileChannel out = null;
        try {
            out = prepareOutputFileChannel(target);
            LOG.debug("Using InputStream to write file: {}", target);
            int size = endpoint.getBufferSize();
            byte[] buffer = new byte[size];
            ByteBuffer byteBuffer = ByteBuffer.wrap(buffer);
            int bytesRead;
            while ((bytesRead = in.read(buffer)) != -1) {
                if (bytesRead < size) {
                    byteBuffer.limit(bytesRead);
                }
                out.write(byteBuffer);
                byteBuffer.clear();
            }
        } finally {
            IOHelper.close(in, target.getName(), LOG);
            IOHelper.close(out, target.getName(), LOG, endpoint.isForceWrites());
        }
    }

    private void writeFileByReaderWithCharset(Reader in, File target, String charset) throws IOException {
        boolean append = endpoint.getFileExist() == GenericFileExist.Append;
        FileOutputStream os = new FileOutputStream(target, append);
        Writer out = IOConverter.toWriter(os, charset);
        try {
            LOG.debug("Using Reader to write file: {} with charset: {}", target, charset);
            int size = endpoint.getBufferSize();
            IOHelper.copy(in, out, size);
        } finally {
            IOHelper.close(in, target.getName(), LOG);
            IOHelper.close(out, os, target.getName(), LOG, endpoint.isForceWrites());
        }
    }

    /**
     * Creates a new file if the file doesn't exist.
     * If the endpoint's existing file logic is set to 'Override' then the target file will be truncated
     */
    private void writeFileEmptyBody(File target) throws IOException {
        if (!target.exists()) {
            LOG.debug("Creating new empty file: {}", target);
            FileUtil.createNewFile(target);
        } else if (endpoint.getFileExist() == GenericFileExist.Override) {
            LOG.debug("Truncating existing file: {}", target);
            FileChannel out = new FileOutputStream(target).getChannel();
            try {
                out.truncate(0);
            } finally {
                IOHelper.close(out, target.getName(), LOG, endpoint.isForceWrites());
            }
        }
    }

    /**
     * Creates and prepares the output file channel. Will position itself in correct position if the file is writable
     * eg. it should append or override any existing content.
     */
    private FileChannel prepareOutputFileChannel(File target) throws IOException {
        if (endpoint.getFileExist() == GenericFileExist.Append) {
            FileChannel out = new RandomAccessFile(target, "rw").getChannel();
            return out.position(out.size());
        }
        return new FileOutputStream(target).getChannel();
    }
}
