/*
 * @(#)SftpFileTransfer.java
 *
 * Copyright (c) 1999-2007 JSCAPE LLC
 *
 * This software is the confidential and proprietary information of
 * JSCAPE. ("Confidential Information").  You shall not disclose such
 * Confidential Information and shall use it only in accordance with
 * the terms of the license agreement you entered into with JSCAPE.
 */
package com.jscape.filetransfer;

import com.jscape.crypto.HashTools;
import com.jscape.crypto.MD5;
import com.jscape.inet.proxy.Proxies;
import com.jscape.inet.proxy.ProxyDescriptor;
import com.jscape.inet.proxy.Proxy;
import com.jscape.inet.proxy.ProxySocketConnector;
import com.jscape.inet.sftp.*;
import com.jscape.inet.ssh.protocol.v2.authentication.*;
import com.jscape.inet.ssh.protocol.v2.connection.SessionConnection;
import com.jscape.inet.ssh.protocol.v2.connection.SessionConnector;
import com.jscape.inet.ssh.protocol.v2.messages.PublicKeyAlgorithm;
import com.jscape.inet.ssh.protocol.v2.transport.keyexchange.HostKeyVerificationService;
import com.jscape.inet.ssh.util.KeyPairAssembler;
import com.jscape.inet.ssh.util.SshParameters;
import com.jscape.util.*;
import com.jscape.util.file.PosixFilePermissions;
import com.jscape.util.file.PosixFilePermissionsFormat;
import com.jscape.util.file.UnixFileType;
import com.jscape.util.io.CountingInputStream;
import com.jscape.util.io.CountingOutputStream;
import com.jscape.util.io.FileInputStream;
import com.jscape.util.io.FileOutputStream;
import com.jscape.util.log.DebugStreamLogger;
import com.jscape.util.net.ConnectionDescriptor;
import com.jscape.util.net.TransportAddress;
import com.jscape.util.net.connection.Connector;
import com.jscape.util.net.connection.SyncRawConnection;
import com.jscape.util.net.socket.SocketConnector;
import com.jscape.util.net.socket.SocketParameters;

import java.io.File;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.security.KeyPair;
import java.util.*;
import java.util.concurrent.Executors;
import java.util.logging.Level;
import java.util.logging.Logger;

import static com.jscape.util.Parameters.*;

/**
 * Implements the basic functionality of a SFTP (FTP over SSH) client.
 * <p/>
 * <p/> Example Usage:
 * <p/>
 * <p/>
 * <p/>
 * <pre>
 *   // connection information
 *   String hostname = &quot;10.0.0.1&quot;;
 *   String username = &quot;jsmith&quot;;
 *   String password = &quot;secret&quot;;
 *
 *   // create com.jscape.inet.ssh.util.SshParameters instance
 *   SshParameters params = new SshParameters(hostname,username,password);
 *
 *   // create SftpTransfer instance
 *   FileTransfer ftp = new SftpTransfer(params)
 *
 *   // connect to server
 *   ftp.connect();
 *
 *   // print directory listing to console
 *   System.out.println(ftp.getDirListingAsString());
 *
 *   // disconnect from server
 *   sftp.disconnect();
 * </pre>
 */
public class SftpFileTransfer
        extends AbstractFileTransfer {

    private static final int DEFAULT_PORT = 22;
    private static final Time DEFAULT_TIMEOUT = Time.seconds(60);
    private static final int DEFAULT_TRANSFER_BUFFER_SIZE = 32750;
    private static final int DEFAULT_PIPELINE_WINDOW_SIZE = SftpFileService3.DEFAULT_PIPELINE_WINDOW_SIZE;
    private static final String DEFAULT_REMOTE_DIRECTORY = ".";

    private static final String FILE_SEPARATOR = PathTools.FILE_SEPARATOR;

    private static final Time ATTEMPT_PERIOD = Time.seconds(10);

    private static final KeyPairAssembler KEY_ASSEMBLER = new KeyPairAssembler();

    private static final int SSH_FX_PERMISSION_DENIED = 3;

    private static final int DEFAULT_VERSION = 3;

    private static final byte[] DEFAULT_EOL = {'\r', '\n'};

    private Integer socketTrafficClass;
    private Boolean tcpNoDelay;
    private HostKeyVerificationService hostKeyVerifier;
    private KeyPair keyPair;
    private ClientAuthentication clientAuthentication;
    private SftpConfiguration configuration;
    private int version;
    private boolean pipelinedTransferEnabled;
    private int pipelineWindowSize;
    private String currentRemoteDirectory;
    private FileService service;
    private volatile UploadDirectoryOperation uploadDirectoryOperation;
    private volatile DownloadDirectoryOperation downloadDirectoryOperation;

    public SftpFileTransfer(String proxyType,
                            String proxyHost,
                            int proxyPort,
                            String proxyUsername,
                            String proxyPassword,
                            String hostname,
                            int port,
                            Time timeout,
                            Integer socketTrafficClass,
                            Boolean tcpNoDelay,
                            HostKeyVerificationService hostKeyVerifier,
                            String username,
                            String password,
                            KeyPair keyPair,
                            ClientAuthentication clientAuthentication,
                            SftpConfiguration configuration,
                            int version,
                            int transferBufferSizeBytes,
                            boolean pipelinedTransferEnabled,
                            int pipelineWindowSize,
                            boolean uploadedFileTimestampPreservingRequired,
                            boolean downloadedFileTimestampPreservingRequired,
                            boolean checksumVerificationRequired,
                            File currentLocalDirectory,
                            String currentRemoteDirectory,
                            Logger logger,
                            Set<FileTransferListener> listeners) {
        super(proxyType,
              proxyHost,
              proxyPort,
              proxyUsername,
              proxyPassword,
              hostname,
              port,
              timeout.toMillis(),
              username,
              password,
              transferBufferSizeBytes,
              false,
              BINARY,
              null,
              uploadedFileTimestampPreservingRequired,
              downloadedFileTimestampPreservingRequired,
              checksumVerificationRequired,
              TimeZone.getDefault(),
              currentLocalDirectory,
              logger,
              listeners);

        this.socketTrafficClass = socketTrafficClass;
        this.tcpNoDelay = tcpNoDelay;

        assertNotNull(hostKeyVerifier);
        this.hostKeyVerifier = hostKeyVerifier;

        this.keyPair = keyPair;
        this.clientAuthentication = clientAuthentication;

        assertNotNull(configuration);
        this.configuration = configuration;

        assertTrue(version == 3 || version == 4, "Unsupported version.");
        this.version = version;

        this.pipelinedTransferEnabled = pipelinedTransferEnabled;

        assertGreater(pipelineWindowSize, 0, "Bad pipeline window size.");
        this.pipelineWindowSize = pipelineWindowSize;

        assertNotNull(currentRemoteDirectory);
        this.currentRemoteDirectory = currentRemoteDirectory;
    }

    /**
     * Creates SftpTransfer instance.
     *
     * @param parameters    the connection parameters
     * @param configuration the configuration
     */
    public SftpFileTransfer(SshParameters parameters, SftpConfiguration configuration, Logger logger) {
        this(parameters.getProxyType(),
             parameters.getProxyHost(),
             parameters.getProxyPort(),
             parameters.getProxyUsername(),
             parameters.getProxyPassword(),
             parameters.getHostname(),
             parameters.getPort(),
             Time.millis(Math.max(parameters.getConnectionTimeout(), parameters.getReadingTimeout())),
             parameters.getSocketTrafficClass(),
             parameters.getTcpNoDelay(),
             parameters.getHostKeyVerifier(),
             parameters.getUsername(),
             parameters.getPassword(),
             parameters.getKeyPair(),
             parameters.getClientAuthentication(),
             configuration,
             DEFAULT_VERSION,
             DEFAULT_TRANSFER_BUFFER_SIZE,
             false,
             DEFAULT_PIPELINE_WINDOW_SIZE,
             false,
             false,
             false,
             DEFAULT_LOCAL_DIRECTORY,
             DEFAULT_REMOTE_DIRECTORY,
             logger,
             Collections.<FileTransferListener>emptySet());
    }

    /**
     * Creates SftpTransfer instance.
     *
     * @param parameters    the connection parameters
     * @param configuration the configuration
     */
    public SftpFileTransfer(SshParameters parameters, SftpConfiguration configuration) {
        this(parameters, configuration, DebugStreamLogger.systemOutLogger(Level.OFF));
    }

    /**
     * Creates SftpTransfer instance.
     *
     * @param parameters Connection parameters for SSH protocol
     */
    public SftpFileTransfer(SshParameters parameters) {
        this(parameters, SftpConfiguration.defaultConfiguration());
    }

    /**
     * Creates SftpTransfer instance.
     *
     * @param host     the hostname of SSH server
     * @param port     the port of SSH server
     * @param username the username of SSH user
     * @param password the password of SSH user
     */
    public SftpFileTransfer(String host, int port, String username, String password) {
        this(new SshParameters(null, null, 0, null, null, host, port, DEFAULT_TIMEOUT.toMillis(), DEFAULT_TIMEOUT.toMillis(), null, null, HostKeyVerificationService.NULL, username, password, null, null, null));
    }

    /**
     * Creates SftpTransfer instance.
     *
     * @param host     the hostname of SSH server
     * @param username the username of SSH user
     * @param password the password of SSH user
     */
    public SftpFileTransfer(String host, String username, String password) {
        this(new SshParameters(null, null, 0, null, null, host, DEFAULT_PORT, DEFAULT_TIMEOUT.toMillis(), DEFAULT_TIMEOUT.toMillis(), null, null, HostKeyVerificationService.NULL, username, password, null, null, null));
    }

    /**
     * Creates SftpTransfer instance.
     *
     * @param host     the hostname of SSH server
     * @param username the username of SSH user
     * @param password the password of SSH user
     * @param keyFile  a private key for session
     */
    public SftpFileTransfer(String host, String username, String password, File keyFile) {
        this(new SshParameters(null, null, 0, null, null, host, DEFAULT_PORT, DEFAULT_TIMEOUT.toMillis(), DEFAULT_TIMEOUT.toMillis(), null, null, HostKeyVerificationService.NULL, username, password, keyFile, "", null, null));
    }

    /**
     * Creates SftpTransfer instance.
     *
     * @param host          the hostname of SSH server
     * @param port          the port of SSH server
     * @param username      the username of SSH user
     * @param password      the password of SSH user
     * @param configuration Sftp configuration instance.
     */
    public SftpFileTransfer(String host, int port, String username, String password, SftpConfiguration configuration) {
        this(new SshParameters(null, null, 0, null, null, host, port, DEFAULT_TIMEOUT.toMillis(), DEFAULT_TIMEOUT.toMillis(), null, null, HostKeyVerificationService.NULL, username, password, null, null, null), configuration);
    }

    public Integer getSocketTrafficClass() {
        return this.socketTrafficClass;
    }

    public SftpFileTransfer setSocketTrafficClass(Integer value) {
        this.socketTrafficClass = value;
        return this;
    }

    public Boolean getTcpNoDelay() {
        return this.tcpNoDelay;
    }

    public SftpFileTransfer setTcpNoDelay(Boolean value) {
        this.tcpNoDelay = value;
        return this;
    }

    public HostKeyVerificationService getHostKeyVerifier() {
        return this.hostKeyVerifier;
    }

    public SftpFileTransfer setHostKeyVerifier(HostKeyVerificationService value) {
        assertNotNull(value);
        this.hostKeyVerifier = value;

        return this;
    }

    public KeyPair getKeyPair() {
        return this.keyPair;
    }

    public SftpFileTransfer setKeyPair(KeyPair value) {
        this.keyPair = value;
        return this;
    }

    public SftpFileTransfer setKeyPair(File keyFile, String filePassword) {
        try {
            this.keyPair = KEY_ASSEMBLER.restoreKeyPair(keyFile, filePassword);
            return this;
        } catch (Exception e) {
            throw new IllegalArgumentException("Unsupported key format.", e);
        }
    }

    public ClientAuthentication getClientAuthentication() {
        return this.clientAuthentication;
    }

    public SftpFileTransfer setClientAuthentication(ClientAuthentication value) {
        this.clientAuthentication = value;
        return this;
    }

    public SftpConfiguration getConfiguration() {
        return this.configuration;
    }

    public SftpFileTransfer setConfiguration(SftpConfiguration value) {
        assertNotNull(value);
        this.configuration = value;

        return this;
    }

    public int getVersion() {
        return this.version;
    }

    public SftpFileTransfer setVersion(int value) {
        assertTrue(version == 3 || version == 4, "Unsupported version.");
        this.version = value;

        return this;
    }

    public boolean isPipelinedTransferEnabled() {
        return this.pipelinedTransferEnabled;
    }

    public SftpFileTransfer setPipelinedTransferEnabled(boolean value) {
        this.pipelinedTransferEnabled = value;
        return this;
    }

    public int getPipelineWindowSize() {
        return this.pipelineWindowSize;
    }

    public SftpFileTransfer setPipelineWindowSize(int value) {
        assertGreater(pipelineWindowSize, 0, "Bad pipeline window size.");
        this.pipelineWindowSize = value;

        return this;
    }

    @Override
    public Object getImplementation() {
        return this.service;
    }

    @Override
    public FileTransfer copy() {
        return new SftpFileTransfer(this.proxyType,
                                    this.proxyHost,
                                    this.proxyPort,
                                    this.proxyUsername,
                                    this.proxyPassword,
                                    this.hostname,
                                    this.port,
                                    Time.millis(this.timeout),
                                    this.socketTrafficClass,
                                    this.tcpNoDelay,
                                    this.hostKeyVerifier,
                                    this.username,
                                    this.password,
                                    this.keyPair,
                                    this.clientAuthentication,
                                    this.configuration,
                                    this.version,
                                    this.transferBufferSizeBytes,
                                    this.pipelinedTransferEnabled,
                                    this.pipelineWindowSize,
                                    this.uploadedFileTimestampPreservingRequired,
                                    this.downloadedFileTimestampPreservingRequired,
                                    this.checksumVerificationRequired,
                                    this.currentLocalDirectory.toFile(),
                                    this.currentRemoteDirectory,
                                    this.logger,
                                    this.listeners);
    }

    @Override
    public SftpFileTransfer connect()
            throws FileTransferException {
        try {
            initService();
            this.service.start(null);
            this.currentRemoteDirectory = this.service.infoAbout(this.currentRemoteDirectory, false).name;
            this.executorService = Executors.newCachedThreadPool(NamedThreadFactory.daemonsFactory("SFTP Transfer"));
            raiseEvent(new FileTransferConnectedEvent(this, this.hostname));

            return this;
        } catch (Exception e) {
            disposeService();
            disposeExecutorService();
            throw wrap(e);
        }
    }

    @Override
    public void disconnect()
            throws FileTransferException {
        if (this.service == null) {
            return;
        }

        disposeService();
        disposeExecutorService();
        raiseEvent(new FileTransferDisconnectedEvent(this, this.hostname));
    }

    @Override
    public boolean isConnected() {
        return this.service != null && this.service.isStarted() && this.service.available();
    }

    @Override
    public String getDir() {
        return this.currentRemoteDirectory;
    }

    @Override
    public FileTransfer setDir(String remoteDirectory)
            throws FileTransferException {
        try {
            String remotePath = resolveRemotePathFor(remoteDirectory);
            UnixFileType type = this.service.typeOf(remotePath, true);

            if (type != UnixFileType.DIRECTORY) {
                throw new Exception(String.format("Specified path '%s' is not a directory.", remoteDirectory));
            }

            this.currentRemoteDirectory = remotePath;

            raiseEvent(new FileTransferChangeDirEvent(this, this.currentRemoteDirectory));
            return this;
        } catch (Exception e) {
            throw wrap(e);
        }
    }

    @Override
    public FileTransfer setDirUp()
            throws FileTransferException {
        setDir(PathTools.PARENT_PATH);
        return this;
    }

    public Enumeration<FileTransferRemoteFile> getDirListing(String remoteDirectory, String regex)
            throws FileTransferException {
        try {
            String remotePath = resolveRemotePathFor(remoteDirectory);
            List<PathInfo> entries = this.service.listDirectory(remotePath);
            Collections.sort(entries, new PathInfo.NaturalComparator());
            Vector<FileTransferRemoteFile> files = new Vector<>();

            for (PathInfo entry : entries) {
                if (entry.name.matches(regex)) {
                    FileTransferRemoteFile file = fileFor(entry);
                    files.add(file);
                }
            }

            return files.elements();
        } catch (Exception e) {
            throw wrap(e);
        }
    }

    @Override
    public Enumeration<FileTransferRemoteFile> getDirListing(String regex)
            throws FileTransferException {
        return getDirListing(this.currentRemoteDirectory, regex);
    }

    @Override
    public void makeDirRecursive(String remoteDirectory)
            throws FileTransferException {
        try {
            String remotePath = resolveRemotePathFor(remoteDirectory);
            String[] elements = remotePath.split(FILE_SEPARATOR);
            String currentPath = "";
            for (String element : elements) {
                if (!element.isEmpty()) {
                    currentPath = resolveRemotePathFor(currentPath, element);
                    if (!exists(currentPath)) {
                        this.service.createDirectory(currentPath, PosixFilePermissions.ownerDirectoryPermissions());
                        raiseEvent(new FileTransferCreateDirEvent(this, currentPath, this.currentRemoteDirectory));

                    }
                }
            }
        } catch (Exception e) {
            throw wrap(e);
        }
    }

    @Override
    public void deleteDir(String remoteDirectory)
            throws FileTransferException {
        try {
            String remotePath = resolveRemotePathFor(remoteDirectory);
            this.service.removeDirectory(remotePath);
            raiseEvent(new FileTransferDeleteDirEvent(this, remoteDirectory, this.currentRemoteDirectory));
        } catch (Exception e) {
            throw wrap(e);
        }
    }

    @Override
    public void deleteDir(String remoteDirectory, boolean recursive)
            throws FileTransferException {
        if (recursive) {
            new RecursiveDeleteOperation(remoteDirectory).applyTo(this);
        } else {
            deleteDir(remoteDirectory);
        }
    }

    @Override
    public boolean exists(String remotePath)
            throws FileTransferException {
        try {
            String absoluteRemotePath = resolveRemotePathFor(remotePath);
            return this.service.exists(absoluteRemotePath);
        } catch (Exception e) {
            throw wrap(e);
        }
    }

    @Override
    public boolean isDirectory(String remotePath)
            throws FileTransferException {
        UnixFileType type = getType(remotePath);
        return type == UnixFileType.DIRECTORY;
    }

    /**
     * Returns Unix file type of the specified remote path.
     *
     * @param remotePath a remote path
     *
     * @return a corresponded Unix file type
     *
     * @throws FileTransferException if an error occurs
     */
    public UnixFileType getType(String remotePath)
            throws FileTransferException {
        try {
            String absoluteRemotePath = resolveRemotePathFor(remotePath);
            return this.service.typeOf(absoluteRemotePath, true);
        } catch (Exception e) {
            throw wrap(e);
        }
    }

    /**
     * Returns remote symbolic link target path.
     *
     * @param remoteFile the remote link file
     *
     * @return remote link target path
     *
     * @throws FileTransferException if an error occurs
     */
    public String getSymbolicLinkTargetPath(String remoteFile)
            throws FileTransferException {
        try {
            String remotePath = resolveRemotePathFor(remoteFile);
            PathInfo pathInfo = this.service.infoAbout(remotePath, true);

            return pathInfo.name;
        } catch (Exception e) {
            throw wrap(e);
        }
    }

    @Override
    public long getFilesize(String remoteFile)
            throws FileTransferException {
        try {
            String remotePath = resolveRemotePathFor(remoteFile);
            return this.service.sizeOf(remotePath, true);
        } catch (Exception e) {
            throw wrap(e);
        }
    }

    /**
     * Gets creation date/timestamp of remote file from SSH server.
     *
     * @param remoteFile the remote filename
     *
     * @return the creation date/timestamp of remote file
     *
     * @throws FileTransferException if an I/O or SSH error occurs.
     */
    public synchronized Date getFileCreationTimestamp(String remoteFile)
            throws FileTransferException {
        try {
            String remotePath = resolveRemotePathFor(remoteFile);
            long time = this.service.createTimeOf(remotePath, true);

            return new Date(time);
        } catch (Exception e) {
            throw wrap(e);
        }
    }

    /**
     * Sets file creation time.
     *
     * @param remoteFile       the remote file name
     * @param timestamp the file creation time
     *
     * @throws FileTransferException if an I/O or SSH error occurs.
     */
    public void setFileCreationTimestamp(String remoteFile, Date timestamp)
            throws FileTransferException {
        try {
            String remotePath = resolveRemotePathFor(remoteFile);
            this.service.createTime(remotePath, timestamp.getTime());
        } catch (Exception e) {
            throw wrap(e);
        }
    }

    /**
     * Gets access date/timestamp of remote file from SSH server.
     *
     * @param remoteFile the remote filename
     *
     * @return the access date/timestamp of remote file
     *
     * @throws FileTransferException if an I/O or SSH error occurs.
     */
    public synchronized Date getFileAccessTimestamp(String remoteFile)
            throws FileTransferException {
        try {
            String remotePath = resolveRemotePathFor(remoteFile);
            long time = this.service.accessTimeOf(remotePath, true);

            return new Date(time);
        } catch (Exception e) {
            throw wrap(e);
        }
    }

    /**
     * Sets file access time.
     *
     * @param remoteFile       the remote file name
     * @param timestamp the file access time
     *
     * @throws FileTransferException if an I/O or SSH error occurs.
     */
    public void setFileAccessTimestamp(String remoteFile, Date timestamp)
            throws FileTransferException {
        try {
            String remotePath = resolveRemotePathFor(remoteFile);
            this.service.accessTime(remotePath, timestamp.getTime());
        } catch (Exception e) {
            throw wrap(e);
        }
    }

    @Override
    public Date getFileTimestamp(String remoteFile)
            throws FileTransferException {
        try {
            String remotePath = resolveRemotePathFor(remoteFile);
            return new Date(this.service.modificationTimeOf(remotePath, true));
        } catch (Exception e) {
            throw wrap(e);
        }
    }

    @Override
    public FileTransfer setFileTimestamp(String remoteFile, Date timestamp)
            throws FileTransferException {
        try {
            String remotePath = resolveRemotePathFor(remoteFile);
            this.service.modificationTime(remotePath, timestamp.getTime());
            return this;
        } catch (Exception e) {
            throw wrap(e);
        }
    }

    /**
     * Gets remote file permissions in UNIX chmod format (644, 777).
     *
     * @param remoteFile the remote filename
     *
     * @return remote file permissions in UNIX chmod format
     *
     * @throws FileTransferException if an I/O or SSH error occurs.
     */
    public int getFilePermissions(String remoteFile)
            throws FileTransferException {
        try {
            String remotePath = resolveRemotePathFor(remoteFile);
            PosixFilePermissions permissions = this.service.permissionsOf(remotePath, false);

            if (permissions == null) {
                throw new FileTransferException("File permissions information is not available.");
            }

            return PosixFilePermissionsFormat.format(permissions);
        } catch (Exception e) {
            throw wrap(e);
        }
    }

    /**
     * Sets file permissions.
     *
     * @param remoteFile  remote file name
     * @param permissions UNIX chmod file permissions
     *
     * @throws FileTransferException if an I/O or SSH error occurs.
     */
    public void setFilePermissions(String remoteFile, int permissions)
            throws FileTransferException {
        try {
            String remotePath = resolveRemotePathFor(remoteFile);
            PosixFilePermissions filePermissions = PosixFilePermissionsFormat.parse(permissions);
            this.service.permissions(remotePath, filePermissions);
        } catch (Exception e) {
            throw wrap(e);
        }
    }

    @Override
    public boolean sameChecksum(File localFile, String remoteFile)
            throws FileTransferException {
        try {
            byte[] localFileHash = HashTools.hashOf(localFile, MD5.defaultHash());
            String remotePath = resolveRemotePathFor(remoteFile);
            byte[] remoteFileHash = this.service.hashOf(remotePath, 0, getFilesize(remoteFile), 0, "md5").hash;

            return Arrays.equals(localFileHash, remoteFileHash);
        } catch (Exception e) {
            throw wrap(e);
        }
    }

    /**
     * Creates a symbolc link.
     *
     * @param remoteLinkFile   a link file
     * @param remoteTargetFile a link target file
     *
     * @throws FileTransferException if an error occurs
     */
    public synchronized void createSymbolicLink(String remoteLinkFile, String remoteTargetFile)
            throws FileTransferException {
        try {
            String linkPath = resolveRemotePathFor(remoteLinkFile);
            String targetPath = resolveRemotePathFor(remoteTargetFile);
            this.service.createSymbolicLink(linkPath, targetPath);
        } catch (Exception e) {
            throw wrap(e);
        }
    }

    @Override
    public void renameFile(String sourceRemoteFile, String destinationRemoteFile)
            throws FileTransferException {
        try {
            String sourcePath = resolveRemotePathFor(sourceRemoteFile);
            String destinationPath = resolveRemotePathFor(destinationRemoteFile);
            this.service.rename(sourcePath, destinationPath);
            raiseEvent(new FileTransferRenameFileEvent(this, sourceRemoteFile, destinationRemoteFile, this.currentRemoteDirectory));
        } catch (Exception e) {
            throw wrap(e);
        }
    }

    @Override
    public void deleteFile(String remoteFile)
            throws FileTransferException {
        try {
            String remotePath = resolveRemotePathFor(remoteFile);
            this.service.removeFile(remotePath);
            raiseEvent(new FileTransferDeleteFileEvent(this, remotePath, this.currentRemoteDirectory));
        } catch (Exception e) {
            throw wrap(e);
        }
    }

    @Override
    public File download(String localFile, String remoteFile)
            throws FileTransferException {
        CountingOutputStream out = null;

        try {
            String remotePath = resolveRemotePathFor(remoteFile);
            long length = this.service.sizeOf(remotePath, true);
            long modificationTime = this.service.modificationTimeOf(remotePath, true);
            File localPath = resolveLocalPathFor(localFile);
            out = new CountingOutputStream(FileOutputStream.streamFor(localPath, 0, true));

            FileService.TransferListener listener = downloadListener(remoteFile,
                                                                     this.currentRemoteDirectory,
                                                                     localPath.getAbsolutePath(),
                                                                     0,
                                                                     length);

            long startTime = Clock.now();
            if (this.pipelinedTransferEnabled) {
                this.service.readFile(remotePath, 0, out, this.transferMode == ASCII, listener, this.executorService);
            } else {
                this.service.readFile(remotePath, 0, out, this.transferMode == ASCII, listener);
            }

            safeCheckChecksum(localPath, remoteFile);
            safeSetModificationTime(localPath, modificationTime);
            raiseDownloadEvent(remoteFile, localPath.getAbsolutePath(), out.writtenBytes(), Clock.elapsedSince(startTime));

            return localPath;
        } catch (Exception e) {
            raiseDownloadErrorEvent(remoteFile, localFile);
            throw wrap(e);
        } finally {
            IOTools.close(out);
        }
    }

    @Override
    public void resumeDownload(OutputStream out, String remoteFile, long remoteFileOffset)
            throws FileTransferException {
        try {
            String remotePath = resolveRemotePathFor(remoteFile);
            long length = getFilesize(remotePath) - remoteFileOffset;
            CountingOutputStream countingOut = new CountingOutputStream(out);

            FileService.TransferListener listener = downloadListener(remoteFile,
                                                                     this.currentRemoteDirectory,
                                                                     "",
                                                                     remoteFileOffset,
                                                                     length);

            long startTime = Clock.now();
            if (this.pipelinedTransferEnabled) {
                this.service.readFile(remotePath, remoteFileOffset, countingOut, this.transferMode == ASCII, listener, this.executorService);
            } else {
                this.service.readFile(remotePath, remoteFileOffset, countingOut, this.transferMode == ASCII, listener);
            }
            raiseDownloadEvent(remoteFile, "", countingOut.writtenBytes(), Clock.elapsedSince(startTime));
        } catch (Exception e) {
            raiseDownloadErrorEvent(remoteFile, "");
            throw wrap(e);
        }
    }

    @Override
    public void resumeDownload(String localFile, String remoteFile, long remoteFileOffset)
            throws FileTransferException {
        CountingOutputStream out = null;

        try {
            String remotePath = resolveRemotePathFor(remoteFile);
            long length = this.service.sizeOf(remotePath, true);
            long modificationTime = this.service.modificationTimeOf(remotePath, true);
            File localPath = resolveLocalPathFor(localFile);
            out = new CountingOutputStream(FileOutputStream.streamFor(localPath, localPath.exists() ? localPath.length() : 0, true));

            FileService.TransferListener listener = downloadListener(remoteFile,
                                                                     this.currentRemoteDirectory,
                                                                     localPath.getAbsolutePath(),
                                                                     remoteFileOffset,
                                                                     length);

            long startTime = Clock.now();
            if (this.pipelinedTransferEnabled) {
                this.service.readFile(remotePath, remoteFileOffset, out, this.transferMode == ASCII, listener, this.executorService);
            } else {
                this.service.readFile(remotePath, remoteFileOffset, out, this.transferMode == ASCII, listener);
            }

            safeCheckChecksum(localPath, remoteFile);
            safeSetModificationTime(localPath, modificationTime);
            raiseDownloadEvent(remoteFile, localPath.getAbsolutePath(), out.writtenBytes(), Clock.elapsedSince(startTime));
        } catch (Exception e) {
            raiseDownloadErrorEvent(remoteFile, localFile);
            throw wrap(e);
        } finally {
            IOTools.close(out);
        }
    }

    @Override
    public void downloadDir(String remoteDirectory, int retryAttempts, boolean checksumVerificationRequired, int threadCount)
            throws FileTransferException {
        String remotePath = resolveRemotePathFor(remoteDirectory);
        this.downloadDirectoryOperation = new DownloadDirectoryOperation(FileTransferOperation.NULL,
                                                                         remotePath,
                                                                         Math.max(retryAttempts + 1, 1),
                                                                         ATTEMPT_PERIOD,
                                                                         FILE_SEPARATOR,
                                                                         threadCount > 0 ? threadCount : null);
        boolean currentChecksumVerificationState = this.checksumVerificationRequired;
        setChecksumVerificationRequired(checksumVerificationRequired);
        this.downloadDirectoryOperation.applyTo(this);
        setChecksumVerificationRequired(currentChecksumVerificationState);
    }

    @Override
    public void upload(File localFile, String remoteFile, boolean shouldAppend)
            throws FileTransferException {
        CountingInputStream in = null;

        try {
            String remotePath = resolveRemotePathFor(remoteFile);
            long length = localFile.length();
            long remoteFileOffset = shouldAppend && this.version == 3 && exists(remoteFile) ? getFilesize(remoteFile) : 0;
            in = new CountingInputStream(FileInputStream.streamFor(localFile, 0));

            FileService.TransferListener listener = uploadListener(remoteFile,
                                                                   this.currentRemoteDirectory,
                                                                   localFile.getAbsolutePath(),
                                                                   0,
                                                                   length);

            long startTime = Clock.now();

            if (shouldAppend && this.version > 3) {
                if (this.pipelinedTransferEnabled) {
                    this.service.appendFile(in, remotePath, this.transferMode == ASCII, listener, this.executorService);
                } else {
                    this.service.appendFile(in, remotePath, this.transferMode == ASCII, listener);
                }
            } else {
                if (this.pipelinedTransferEnabled) {
                    this.service.writeFile(in, remotePath, remoteFileOffset, this.transferMode == ASCII, listener, this.executorService);
                } else {
                    this.service.writeFile(in, remotePath, remoteFileOffset, this.transferMode == ASCII, listener);
                }
            }

            safeCheckChecksum(localFile, remoteFile);
            safeSetModificationTime(localFile, remoteFile);
            raiseUploadEvent(localFile.getAbsolutePath(), remoteFile, in.readBytes(), Clock.elapsedSince(startTime));
        } catch (Exception e) {
            raiseUploadErrorEvent(localFile.getAbsolutePath(), remoteFile);
            throw wrap(e);
        } finally {
            IOTools.close(in);
        }
    }

    @Override
    public String uploadUnique(InputStream in, String remoteFilePrefix)
            throws FileTransferException {
        try {
            String remoteFile = new FindUniqueFilenameOperation(remoteFilePrefix).applyTo(this).filename();
            upload(in, in.available(), remoteFile, false);

            return remoteFile;
        } catch (Exception e) {
            throw wrap(e);
        }
    }

    @Override
    public void resumeUpload(InputStream in, long length, String remoteFile, long remoteFileOffset)
            throws FileTransferException {
        try {
            String remotePath = resolveRemotePathFor(remoteFile);
            CountingInputStream countingIn = new CountingInputStream(in);
            FileService.TransferListener listener = uploadListener(remoteFile,
                                                                   this.currentRemoteDirectory,
                                                                   "",
                                                                   remoteFileOffset,
                                                                   length);

            long startTime = Clock.now();

            if (this.pipelinedTransferEnabled) {
                this.service.writeFile(in, remotePath, remoteFileOffset, this.transferMode == ASCII, listener, this.executorService);
            } else {
                this.service.writeFile(in, remotePath, remoteFileOffset, this.transferMode == ASCII, listener);
            }
            raiseUploadEvent("", remoteFile, countingIn.readBytes(), Clock.elapsedSince(startTime));
        } catch (Exception e) {
            raiseUploadErrorEvent("", remoteFile);
            throw wrap(e);
        }
    }

    @Override
    public void resumeUpload(File localFile, String remoteFile, long localFileOffset)
            throws FileTransferException {
        CountingInputStream in = null;

        try {
            String remotePath = resolveRemotePathFor(remoteFile);
            long length = localFile.length() - localFileOffset;
            in = new CountingInputStream(FileInputStream.streamFor(localFile, localFileOffset));

            FileService.TransferListener listener = uploadListener(localFile.getName(),
                                                                   this.currentRemoteDirectory,
                                                                   localFile.getAbsolutePath(),
                                                                   localFileOffset,
                                                                   length);

            long startTime = Clock.now();

            if (this.pipelinedTransferEnabled) {
                this.service.writeFile(in, remotePath, localFileOffset, this.transferMode == ASCII, listener, this.executorService);
            } else {
                this.service.writeFile(in, remotePath, localFileOffset, this.transferMode == ASCII, listener);
            }

            safeCheckChecksum(localFile, remoteFile);
            safeSetModificationTime(localFile, remoteFile);
            raiseUploadEvent(localFile.getAbsolutePath(), remoteFile, in.readBytes(), Clock.elapsedSince(startTime));
        } catch (Exception e) {
            raiseUploadErrorEvent(localFile.getAbsolutePath(), remoteFile);
            throw wrap(e);
        } finally {
            IOTools.close(in);
        }
    }

    @Override
    public void uploadDir(File localDirectory, int retryAttempts, boolean checksumVerificationRequired, String tempFileExtension, int threadCount)
            throws FileTransferException {
        this.uploadDirectoryOperation = new UploadDirectoryOperation(FileTransferOperation.NULL,
                                                                     localDirectory,
                                                                     tempFileExtension,
                                                                     Math.max(retryAttempts + 1, 1),
                                                                     ATTEMPT_PERIOD,
                                                                     threadCount > 0 ? threadCount : null);
        boolean currentChecksumVerificationState = this.checksumVerificationRequired;
        setChecksumVerificationRequired(checksumVerificationRequired);
        this.uploadDirectoryOperation.applyTo(this);
        setChecksumVerificationRequired(currentChecksumVerificationState);
    }

    @Override
    public void interrupt() {
        super.interrupt();

        if (this.service != null) {
            this.service.cancelTransferOperation();
        }
    }

    @Override
    public void abortUploadThreads() {
        UploadDirectoryOperation operation = this.uploadDirectoryOperation;

        if (operation != null) {
            operation.cancel();
        }
    }

    @Override
    public void abortUploadThread(String remoteFile) {
        UploadDirectoryOperation operation = this.uploadDirectoryOperation;

        if (operation != null) {
            operation.cancel(new File(remoteFile));
        }
    }

    @Override
    public void abortDownloadThreads() {
        DownloadDirectoryOperation operation = this.downloadDirectoryOperation;

        if (operation != null) {
            operation.cancel();
        }
    }

    @Override
    public void abortDownloadThread(String remoteFile) {
        DownloadDirectoryOperation operation = this.downloadDirectoryOperation;

        if (operation != null) {
            operation.cancel(remoteFile);
        }
    }

    protected void initService()
            throws Exception {
        ClientAuthentication authentication = authentication();
        Connector<SyncRawConnection> rawConnector = rawConnector();
        Connector<SessionConnection> connector = new SessionConnector(rawConnector,
                                                                      this.configuration.sshConfiguration.identificationString,
                                                                      this.configuration.sshConfiguration.publicKeyAlgorithms,
                                                                      this.configuration.sshConfiguration.keyExchangeFactory,
                                                                      this.configuration.sshConfiguration.encryptionFactory,
                                                                      this.configuration.sshConfiguration.macFactory,
                                                                      this.configuration.sshConfiguration.compressionFactory,
                                                                      this.hostKeyVerifier,
                                                                      authentication,
                                                                      this.configuration.sshConfiguration.initialWindowSize,
                                                                      this.configuration.sshConfiguration.maxPacketSize,
                                                                      this.logger);

        if (this.version == 4) {
            byte[] systemEol = System.getProperty("line.separator", "\r\n").getBytes(StandardCharsets.US_ASCII);
            this.service = new SftpFileService4(connector,
                                                this.transferBufferSizeBytes,
                                                this.configuration.subsystemResponseRequired,
                                                systemEol,
                                                this.pipelineWindowSize,
                                                this.logger);
        } else {
            this.service = new SftpFileService3(connector,
                                                this.transferBufferSizeBytes,
                                                this.configuration.subsystemResponseRequired,
                                                this.pipelineWindowSize,
                                                this.logger);
        }
    }

    protected void disposeService() {
        if (this.service != null) {
            this.service.dispose();
            this.service = null;
        }
    }

    protected Connector<SyncRawConnection> rawConnector() {
        ConnectionDescriptor connectionDescriptor = new ConnectionDescriptor(new TransportAddress(this.hostname, this.port), this.timeout);
        SocketParameters socketParameters = new SocketParameters(true, null, null, this.socketTrafficClass, this.tcpNoDelay, null, null, Time.millis(this.timeout));

        if (this.proxyType != null && this.proxyHost != null) {
            ProxyDescriptor proxyDescriptor = new ProxyDescriptor(this.proxyHost,
                                                                  this.proxyPort,
                                                                  this.proxyUsername,
                                                                  this.proxyPassword);
            Proxy proxy = Proxies.proxyFor(this.proxyType);
            return new ProxySocketConnector(proxyDescriptor, proxy, connectionDescriptor, socketParameters);
        } else {
            return new SocketConnector(connectionDescriptor, socketParameters);
        }
    }

    protected ClientAuthentication authentication() {
        if (this.clientAuthentication != null) {
            return this.clientAuthentication;
        } else if (StringTools.isValidString(this.password) && this.keyPair != null) {
            return new ComponentClientAuthentication(PublicKeyClientAuthentication.defaultAuthentication(this.username, PublicKeyAlgorithm.algorithmFor(this.keyPair.getPublic()), this.keyPair),
                                                     PasswordClientAuthentication.defaultAuthentication(this.username, this.password),
                                                     KeyboardInteractiveRegexClientAuthentication.defaultAuthentication(this.username, new KeyboardInteractiveRegexClientAuthentication.Entry(".*[pP][aA][sS][sS][wW][oO][rR][dD].*", this.password)));
        } else if (StringTools.isValidString(this.password)) {
            return new ComponentClientAuthentication(PasswordClientAuthentication.defaultAuthentication(this.username, this.password),
                                                     KeyboardInteractiveRegexClientAuthentication.defaultAuthentication(this.username, new KeyboardInteractiveRegexClientAuthentication.Entry(".*[pP][aA][sS][sS][wW][oO][rR][dD].*", this.password)));
        } else if (this.keyPair != null) {
            return PublicKeyClientAuthentication.defaultAuthentication(this.username, PublicKeyAlgorithm.algorithmFor(this.keyPair.getPublic()), this.keyPair);
        } else {
            return NoneClientAuthentication.defaultAuthentication(this.username);
        }
    }

    private String resolveRemotePathFor(String directory, String path) {
        return PathTools.resolve(directory, path);
    }

    private String resolveRemotePathFor(String path) {
        return resolveRemotePathFor(this.currentRemoteDirectory, path);
    }

    private FileTransferRemoteFile fileFor(PathInfo entry) {
        return new FileTransferRemoteFile(entry.name,
                                          "",
                                          entry.directory(),
                                          entry.link(),
                                          entry.size,
                                          new Date(entry.modificationTime),
                                          entry.readable(),
                                          entry.writable());
    }

    private FileService.TransferListener downloadListener(String fileName,
                                                          String path,
                                                          String localFilePath,
                                                          long startBytes,
                                                          long totalBytes) {
        return new TransferProgressListener(fileName,
                                            path,
                                            localFilePath,
                                            FileTransferProgressEvent.DOWNLOAD,
                                            startBytes,
                                            totalBytes);
    }

    private FileService.TransferListener uploadListener(String fileName,
                                                        String path,
                                                        String localFilePath,
                                                        long startBytes,
                                                        long totalBytes) {
        return new TransferProgressListener(fileName,
                                            path,
                                            localFilePath,
                                            FileTransferProgressEvent.UPLOAD,
                                            startBytes,
                                            totalBytes);
    }

    private void raiseDownloadEvent(String remoteFile, String localPath, long transferredBytes, long takenTime) {
        raiseEvent(new FileTransferDownloadEvent(this,
                                                 remoteFile,
                                                 this.currentRemoteDirectory,
                                                 localPath,
                                                 transferredBytes,
                                                 takenTime));
    }

    private void raiseDownloadErrorEvent(String remoteFile, String localPath) {
        raiseEvent(new FileTransferErrorEvent(this,
                                              remoteFile,
                                              this.currentRemoteDirectory,
                                              localPath,
                                              FileTransferProgressEvent.DOWNLOAD));
    }

    private void raiseUploadEvent(String localPath, String remoteFile, long transferredBytes, long takenTime) {
        raiseEvent(new FileTransferUploadEvent(this,
                                               remoteFile,
                                               this.currentRemoteDirectory,
                                               localPath,
                                               transferredBytes,
                                               takenTime));
    }

    private void raiseUploadErrorEvent(String localPath, String remoteFile) {
        raiseEvent(new FileTransferErrorEvent(this,
                                              remoteFile,
                                              this.currentRemoteDirectory,
                                              localPath,
                                              FileTransferProgressEvent.UPLOAD));
    }

    private FileTransferException wrap(Throwable e) {
        if (e instanceof FileService.ServerException) {
            return wrap((FileService.ServerException) e);
        } else if (e.getCause() instanceof FileService.ServerException) {
            return wrap((FileService.ServerException) e.getCause());
        } else if (e.getCause() != null && e.getCause().getCause() instanceof ClientAuthentication.InvalidCredentialsException) {
            return new FileTransferAuthenticationException();
        }

        return FileTransferException.wrap(e);
    }

    private FileTransferException wrap(FileService.ServerException e) {
        if (SSH_FX_PERMISSION_DENIED == e.code) {
            return new FileTransferDeniedException();
        }

        return new FileTransferException(e);
    }

    private class TransferProgressListener
            implements FileService.TransferListener {

        private final String fileName;
        private final String path;
        private final String localFilePath;
        private final int mode;
        private final long startBytes;
        private final long totalBytes;

        private TransferProgressListener(String fileName,
                                         String path,
                                         String localFilePath,
                                         int mode,
                                         long startBytes,
                                         long totalBytes) {
            this.fileName = fileName;
            this.path = path;
            this.localFilePath = localFilePath;
            this.mode = mode;
            this.startBytes = startBytes;
            this.totalBytes = totalBytes;
        }

        public void onTransferProgress(long transferredBytes) {
            raiseEvent(new FileTransferProgressEvent(SftpFileTransfer.this,
                                                     this.fileName,
                                                     this.path,
                                                     this.localFilePath,
                                                     this.mode,
                                                     this.startBytes + transferredBytes,
                                                     0,
                                                     this.totalBytes));

        }
    }

}
