/*
 * @(#)Sftp.java
 *
 * Copyright (c) 1999-2007 JSCAPE LLC
 *
 * This software is the confidential and proprietary information of
 * JSCAPE LLC. ("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.inet.sftp;

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.events.*;
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.util.SshHostKeys;
import com.jscape.inet.ssh.util.SshParameters;
import com.jscape.util.*;
import com.jscape.util.collections.Tree;
import com.jscape.util.file.PosixFilePermissions;
import com.jscape.util.file.PosixFilePermissionsFormat;
import com.jscape.util.file.UnixFileType;
import com.jscape.util.io.*;
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 com.jscape.util.service.Service;

import java.io.*;
import java.io.ByteArrayInputStream;
import java.net.ConnectException;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.FileTime;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
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>
 * <pre>
 * // connection information
 * String hostname = "10.0.0.1";
 * String username = "jsmith";
 * String password = "secret";
 *
 * // create com.jscape.inet.ssh.util.SshParameters instance
 * SshParameters params = new SshParameters(hostname,username,password);
 *
 * // set key exchanges, ciphers, macs and compressions if needed
 * SshConfiguration sshConfiguration = SshConfiguration.configurationFor(new ComponentClientKeyExchangeFactory(ClientKeyExchanges.DH_GROUP_SHA256, ClientKeyExchanges.DH_GROUP_SHA1),
 *                                                                      new JceEncryptionFactory(JceEncryptions.AES256_CBC, JceEncryptions.BLOWFISH_CBC),
 *                                                                      new JceMacFactory(JceMacs.HMAC_SHA2_512, JceMacs.HMAC_SHA1),
 *                                                                      new ComponentCompressionFactory(Compressions.NONE));
 *
 * SftpConfiguration configuration = new SftpConfiguration(sshConfiguration, true);
 *
 * // create Sftp instance
 * Sftp sftp = new Sftp(params, configuration);
 *
 * // connect to SSH server
 * sftp.connect();
 *
 * // print directory listing to console
 * System.out.println(sftp.getDirListingAsString());
 *
 * // disconnect from SSH server
 * sftp.disconnect();
 * </pre>
 */
@SuppressWarnings("unchecked")
public class Sftp
        implements AutoCloseable {

    public static final int DEFAULT_VERSION = 3;

    public static final int AUTO = 0;
    public static final int ASCII = 1;
    public static final int BINARY = 2;

    private static final File DEFAULT_LOCAL_DIRECTORY = new File(".");
    private static final String DEFAULT_REMOTE_DIRECTORY = ".";
    private static final int DEFAULT_BUFFER_SIZE = 32750;
    private static final int DEFAULT_PIPELINE_WINDOW_SIZE = SftpFileService3.DEFAULT_PIPELINE_WINDOW_SIZE;

    private static final String FILE_SEPARATOR = PathTools.FILE_SEPARATOR;

    private static final int SSH_FX_NO_SUCH_FILE = 2;
    private static final int SSH_FX_PERMISSION_DENIED = 3;

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

    private SshParameters parameters;
    private SftpConfiguration configuration;
    private int version;
    private boolean automaticVersionLoweringEnabled;
    private boolean pipelinedTransferEnabled;
    private String endOfLine;
    private Path currentLocalDirectory;
    private String currentRemoteDirectory;
    private final Logger logger;
    private final Set<SftpListener> listeners;
    private final SshHostKeys hostKeys;
    private int downloadBufferSizeBytes;
    private int uploadBufferSizeBytes;
    private int pipelineWindowSize;
    private int mode;
    private PosixFilePermissions defaultDirectoryPermissions;
    private volatile boolean interrupted;
    private ExecutorService executorService;
    private FileService service;
    private volatile UploadDirectoryOperation uploadDirectoryOperation;
    private volatile DownloadDirectoryOperation downloadDirectoryOperation;

    /**
     * Clears proxy server values.
     */
    public static void clearProxySettings() {
        // todo: what's this?
        Properties props = System.getProperties();
        props.remove("socksProxyHost");
        props.remove("socksProxyPort");
        props.remove("java.net.socks.username");
        props.remove("java.net.socks.password");
        System.setProperties(props);
    }

    /**
     * Creates a new client instance.
     *
     * @param parameters   the connection parameters
     * @param configuration   the connection configuration
     * @param currentLocalDirectory the local working directory
     */
    public Sftp(SshParameters parameters,
                SftpConfiguration configuration,
                int version,
                File currentLocalDirectory,
                String currentRemoteDirectory,
                int transferBufferSizeBytes,
                int pipelineWindowSize,
                Logger logger) {
        assertLicenseValid();

        assertNotNull(parameters);
        this.parameters = parameters;

        assertNotNull(configuration);
        this.configuration = configuration;

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

        assertTrue(currentLocalDirectory.isDirectory(), "Bad local directory.");
        this.currentLocalDirectory = currentLocalDirectory.toPath().normalize();

        assertNotNull(currentRemoteDirectory);
        this.currentRemoteDirectory = currentRemoteDirectory;

        this.downloadBufferSizeBytes = transferBufferSizeBytes;
        this.uploadBufferSizeBytes = transferBufferSizeBytes;

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

        this.mode = BINARY;
        this.defaultDirectoryPermissions = PosixFilePermissions.ownerDirectoryPermissions();

        assertNotNull(logger);
        this.logger = logger;

        this.listeners = new CopyOnWriteArraySet<>();
        this.hostKeys = new SshHostKeys();
    }

    /**
     * Creates a new SFTP client instance.
     *
     * @param parameters a SSH parameters
     * @param currentLocalDirectory a current local directory
     * @param configuration a SSH configuration
     */
    public Sftp(SshParameters parameters, File currentLocalDirectory, SftpConfiguration configuration) {
        this(parameters,
             configuration,
             DEFAULT_VERSION,
             currentLocalDirectory,
             DEFAULT_REMOTE_DIRECTORY,
             DEFAULT_BUFFER_SIZE,
             DEFAULT_PIPELINE_WINDOW_SIZE,
             DebugStreamLogger.systemOutLogger(Level.OFF));
    }

    /**
     * Creates a new client instance.
     *
     * @param parameters the connection parameters
     * @param configuration the connection configuration
     */
    public Sftp(SshParameters parameters, SftpConfiguration configuration) {
        this(parameters, DEFAULT_LOCAL_DIRECTORY, configuration);
    }

    /**
     * Creates a new client instance.
     *
     * @param parameters   the connection parameters
     * @param currentLocalDirectory the local working directory
     */
    public Sftp(SshParameters parameters, File currentLocalDirectory) {
        this(parameters, currentLocalDirectory, SftpConfiguration.defaultConfiguration());
    }

    /**
     * Creates a new client instance.
     *
     * @param parameters the connection parameters
     */
    public Sftp(SshParameters parameters) {
        this(parameters, DEFAULT_LOCAL_DIRECTORY);
    }

    /**
     * Get SSH parameters.
     *
     * @return params
     */
    public SshParameters getSshParameters() {
        return getParameters();
    }

    public SshParameters getParameters() {
        return this.parameters;
    }

    public void setParameters(SshParameters value) {
        assertNotNull(value);
        this.parameters = value;
    }

    /**
     * Returns the current configuration.
     *
     * @return the current configuration
     */
    public SftpConfiguration getConfiguration() {
        return this.configuration;
    }

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

    /**
     * Returns current target SFTP version.
     *
     * @return current target SFTP version number
     */
    public int getVersion() {
        return this.version;
    }

    /**
     * Sets current target SFTP version.
     *
     * @param value target SFTP version number
     */
    public void setVersion(int value) {
        assertTrue(value == 3 || value == 4, "Unsupported version.");
        this.version = value;
    }

    /**
     * Returns automatic protocol version lowering flag.
     *
     * @return <code>true</code> if automatic protocol version lowering is enabled; <code>false</code> otherwise
     */
    public boolean isAutomaticVersionLoweringEnabled() {
        return this.automaticVersionLoweringEnabled;
    }

    /**
     * Sets automatic version lowering flag.
     *
     * @param value if <code>true</code> the protocol version will be automatically lowered if server doesn't support current version.
     */
    public void setAutomaticVersionLoweringEnabled(boolean value) {
        this.automaticVersionLoweringEnabled = value;
    }

    /**
     * Sets the proxy type will be used for this FTP connection.
     *
     * @param proxyType The proxy type. Values allowed: HTTP, SOCKS5
     */
    public void setProxyType(String proxyType) {
        this.parameters.setProxyType(proxyType);
    }

    /**
     * Sets the proxy authentication username and password to use with
     * proxy server.
     * To clear these settings invoke the #clearProxySettings method.
     *
     * @param proxyUsername the proxy username
     * @param proxyPassword the proxy password
     */
    public void setProxyAuthentication(String proxyUsername, String proxyPassword) {
        this.parameters.setProxyUsername(proxyUsername);
        this.parameters.setProxyPassword(proxyPassword);
    }

    /**
     * Sets the proxy hostname and port for this connection.
     * To clear these settings invoke the #clearProxySettings method.
     *
     * @param proxyHostname the hostname or ip address of the proxy server
     * @param proxyPort     the port of the proxy server to connect to
     */
    public void setProxyHost(String proxyHostname, int proxyPort) {
        this.parameters.setProxyHost(proxyHostname);
        this.parameters.setProxyPort(proxyPort);
    }

    /**
     * Gets the current connection timeout value.
     *
     * @return the timeout in milliseconds
     *
     * @deprecated use getParameters()
     */
    public long getTimeout() {
        return this.parameters.getConnectionTimeout();
    }

    /**
     * Sets the timeout for opening connection to SSH server.
     *
     * @param timeout the timeout in milliseconds
     *
     * @deprecated use setParameters()
     */
    public void setTimeout(long timeout) {
        this.parameters.setConnectionTimeout(timeout);
        this.parameters.setReadingTimeout(timeout);
    }

    /**
     * Gets data transfer mode for current session.
     * Default mode is AUTO where 0 = AUTO, 1 = ASCII, 2 = BINARY
     *
     * @return the data transfer mode
     */
    public int getMode() {
        return this.mode;
    }

    /**
     * Sets data transfer mode to ASCII. You must be connected to the SSH
     * server prior to invoking this method.
     */
    public void setAscii() {
        this.mode = ASCII;
    }

    /**
     * Sets data transfer mode to binary. You must be connected to the SSH
     * server prior to invoking this method.
     */
    public void setBinary() {
        this.mode = BINARY;
    }

    /**
     * Sets data transfer mode to automatic detect
     * Any files with a base Content-Type value of text will be transferred
     * using ASCII, all other files will be transfered using binary.
     * <p/>
     * Example: file.txt, Content-Type = text/plain, uploaded using ASCII
     * <br>
     * Example: image.jpg Content-Type = image/jpg, uploaded using binary
     *
     * @param auto <code>true</code> to turn automatic detect on
     *             <code>false</code> to turn off
     */
    public void setAuto(boolean auto) {
        this.mode = auto ? AUTO : BINARY;
    }

    public PosixFilePermissions getDefaultDirectoryPermissions() {
        return this.defaultDirectoryPermissions;
    }

    public void setDefaultDirectoryPermissions(PosixFilePermissions value) {
        assertNotNull(value);
        this.defaultDirectoryPermissions = value;
    }

    public void setDefaultDirectoryPermissions(int value) {
        setDefaultDirectoryPermissions(PosixFilePermissionsFormat.parse(value));
    }

    /**
     * Gets upload block size.
     *
     * @return the upload block size in bytes
     */
    public int getBlockSize() {
        return getUploadBlockSize();
    }

    /**
     * Sets upload block size.
     *
     * @param blockSize the size in bytes
     *
     * @deprecated see #setUploadBlockSize
     */
    public void setBlockSize(int blockSize) {
        setUploadBlockSize(blockSize);
    }

    /**
     * Gets the current upload buffer size.
     *
     * @return the current upload buffer size
     */
    public int getUploadBlockSize() {
        return this.uploadBufferSizeBytes;
    }

    /**
     * Sets the transfer buffer size for upload operations.
     *
     * @param value desired buffer size
     */
    public void setUploadBlockSize(int value) {
        this.uploadBufferSizeBytes = value;
    }

    /**
     * Gets the current download buffer size.
     *
     * @return the current download buffer size
     */
    public int getDownloadBlockSize() {
        return this.downloadBufferSizeBytes;
    }

    /**
     * Sets the transfer buffer size for download operation.
     * The block size should mot exceed 64 Kb for the pipe-lining transfers.
     *
     * @param blockSize desired buffer size
     */
    public void setDownloadBlockSize(int blockSize) {
        this.downloadBufferSizeBytes = blockSize;
    }

    /**
     * Sets the send buffer size option of the sftp connection.
     *
     * @param value Send buffer size.
     */
    public void setSendBufferSize(int value) {
        // todo: useless
    }

    /**
     * Sets the receive buffer size option of the sftp connection.
     *
     * @param value Receive buffer size.
     */
    public void setReceiveBufferSize(int value) {
        // todo: useless
    }

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

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

    /**
     * Sets the window size (a number of request packets without response) in pipeline transfer mode.
     *
     * @return the current window size
     */
    public int getPipelineWindowSize() {
        return this.pipelineWindowSize;
    }

    /**
     * Sets the window size (a number of request packets without response) in pipeline transfer mode.
     *
     * @param value a window size
     */
    public void setPipelineWindowSize(int value) {
        assertGreater(pipelineWindowSize, 0, "Bad pipeline window size.");
        this.pipelineWindowSize = value;
    }

    /**
     * Sets destination line terminator used in ASCII file transfers.
     * Default is <code>null</code>.  If <code>null</code>, SFTP server must support ASCII transfers
     * and it will be the responsibility of server to perform conversion.  Otherwise,
     * client will perform conversion from local system line terminator to specified
     * destination line terminator during transfer.
     *
     * @param value line terminator
     */
    public void setLineTerminator(String value) {
        this.endOfLine = value;

        if (this.endOfLine != null && !this.endOfLine.isEmpty() && this.service instanceof SftpFileService4) {
            ((SftpFileService4) this.service).systemEol(this.endOfLine.getBytes(StandardCharsets.US_ASCII));
        }
    }

    /**
     * Gets destination line terminator used in ASCII file transfers.
     * Default is <code>null</code>.  If <code>null</code>, SFTP server must support ASCII transfers
     * and it will be the responsibility of server to perform conversion.  Otherwise,
     * client will perform conversion from local system line terminator to specified
     * destination line terminator during transfer.
     *
     * @return current line terminator
     */
    public String getLineTerminator() {
        return this.endOfLine;
    }

    /**
     * Returns current log level.
     *
     * @return current log level
     */
    public Level getLogLevel() {
        return this.logger.getLevel();
    }

    /**
     * Sets current log level.
     *
     * @param level new log level
     */
    public void setLogLevel(Level level) {
        this.logger.setLevel(level);
    }

    /**
     * Returns debug mode flag.
     *
     * @return <code>true</code> if debug mode is turned on
     */
    public boolean getDebug() {
        return this.logger.getLevel() == Level.FINEST;
    }

    /**
     * Turns on/off debug log level.
     *
     * @param value if <code>true</code> debug level will bw turned on
     */
    public void setDebug(boolean value) {
        this.logger.setLevel(value ? Level.FINEST : Level.OFF);
    }

    /**
     * Turn off file access logging. This is different from Sftp.setDebug(false), file access subsystem
     * has it's own file logging facility, this method maybe needed to prevent file logging stack-traces
     * & exceptions from being printed to the console. Such an exception may arise if the connection to
     * the remote server is lost during an operation. E.g the server drops the connection during a file
     * upload.
     */
    public void disableFileAccessLogging() {
        Logger.getLogger("com.jscape.util").setLevel(Level.OFF);
    }

    /**
     * Turn on file access logging. This is different from Sftp.setDebug(true), file access subsystem
     * has it's own file logging facility, this method maybe needed to enable file logging stack-traces
     * & exceptions and have them printed to the console. Such an exception may arise if the connection
     * to the remote server is lost during an operation. E.g the server drops the connection during a file
     * upload.
     */
    public void enableFileAccessLogging() {
        Logger.getLogger("com.jscape.util").setLevel(Level.WARNING);
    }

    /**
     * Returns current debug stream if any.
     *
     * @return current debug stream
     */
    public PrintStream getDebugStream() {
        return this.logger instanceof DebugStreamLogger ? (PrintStream) ((DebugStreamLogger) this.logger).stream() : null;
    }

    /**
     * Sets a debug stream. Works only if DebugStreamLogger was set while instance creation.
     *
     * @param value debug stream
     */
    public void setDebugStream(PrintStream value) {
        if (this.logger instanceof DebugStreamLogger) {
            DebugStreamLogger debugStreamLogger = (DebugStreamLogger) this.logger;
            debugStreamLogger.stream(value);
        }
    }

    /**
     * Adds event listener.
     *
     * @param listener the listener to add
     */
    public void addSftpListener(SftpListener listener) {
        assertNotNull(listener);
        this.listeners.add(listener);
    }

    /**
     * Removes specified SSH event listener.
     *
     * @param listener the listener to remove
     */
    public void removeSftpListener(SftpListener listener) {
        this.listeners.remove(listener);
    }

    /**
     * Returns SSH host keys for further use.
     *
     * @return SSH host keys
     */
    public SshHostKeys getHostKeys() {
        return this.hostKeys;
    }

    /**
     * Gets local directory to be used in SSH session.
     * Downloads will be stored relative to local directory.
     * Uploads are performed relative to current local directory.
     *
     * @return the local directory
     */
    public File getLocalDir() {
        return this.currentLocalDirectory.toFile();
    }

    /**
     * Sets local directory to be used in session.
     * Downloads will be stored relative to local directory.
     * Uploads are performed relative to current local directory.
     *
     * @param value the local directory
     */
    public void setLocalDir(File value) {
        assertTrue(value.isDirectory(), "Invalid local directory.");
        this.currentLocalDirectory = value.toPath().normalize();
    }

    /**
     * Gets local directory listing.
     *
     * @param regex Regular expression used to obtain the file list
     *
     * @return an <code>Enumeration</code> of <code>File</code>
     */
    public Enumeration getLocalDirListing(final String regex) {
        File[] files = this.currentLocalDirectory.toFile().listFiles(new FileFilter() {
            @Override
            public boolean accept(File pathname) {
                return pathname.getName().matches(regex);
            }
        });

        return files != null ? Collections.enumeration(Arrays.asList(files)) : new Vector<File>().elements();
    }

    /**
     * Gets local directory listing.
     *
     * @return an <code>Enumeration</code> of <code>File</code>
     */
    public synchronized Enumeration getLocalDirListing() {
        return getLocalDirListing(".+");
    }

    /**
     * Creates local directory.
     * Directory is relative to current local directory
     *
     * @param localDirectory name of directory
     *
     * @return the created directory
     */
    public File makeLocalDir(String localDirectory)
            throws SftpException {
        try {
            Path localPath = this.currentLocalDirectory.resolve(localDirectory);
            return Files.createDirectories(localPath).toFile();
        } catch (Exception e) {
            throw wrap(e);
        }
    }

    /**
     * Establishes secure connection with SSH server.
     *
     * @throws SftpException if an I/O or SSH error occurs.
     * @see com.jscape.inet.ssh.util.SshParameters
     */
    public synchronized Sftp connect()
            throws SftpException {
        try {
            startService();
            SessionConnection sessionConnection = this.service instanceof SftpFileService4 ? ((SftpFileService4) this.service).sessionConnection() : ((SftpFileService3) this.service).sessionConnection();
            this.hostKeys.put(InetAddress.getByName(this.parameters.getHostname()), sessionConnection.hostKey());
            this.currentRemoteDirectory = this.service.infoAbout(this.currentRemoteDirectory, false).name;
            this.executorService = Executors.newCachedThreadPool(NamedThreadFactory.daemonsFactory("SFTP Transfer"));
            raiseEvent(new SftpConnectedEvent(this, this.parameters.getHostname(), this.parameters.getPort()));

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

    /**
     * Disconnects from the remote host.
     */
    public synchronized void disconnect() {
        if (this.service == null || !this.service.isStarted()) {
            return;
        }

        disposeService();
        shutdownExecutorService();

        raiseEvent(new SftpDisconnectedEvent(this, this.parameters.getHostname(), this.parameters.getPort()));
    }

    @Override
    public void close() {
        disconnect();
    }

    /**
     * Tests whether the current service is connected to the remote host.
     *
     * @return <code>true</code> if the service is connected;
     *         <code>false</code> otherwise
     */
    public synchronized boolean isConnected() {
        return this.service != null && this.service.isStarted() && this.service.available();
    }

    /**
     * Gets current working directory of SSH server.
     *
     * @return the current working directory
     */
    public synchronized String getDir() {
        assertConnected();
        return this.currentRemoteDirectory;
    }

    /**
     * Sets current directory on SSH server.
     *
     * @param remoteDirectory the remote directory
     *
     * @throws SftpException if an I/O or SSH error occurs.
     */
    public synchronized void setDir(String remoteDirectory)
            throws SftpException {
        assertConnected();

        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 SftpChangeDirEvent(this, this.currentRemoteDirectory));
        } catch (Exception e) {
            throw wrap(e);
        }
    }

    /**
     * Sets current directory on SSH server to parent directory.
     *
     * @throws SftpException If an error occurs
     */
    public synchronized void setDirUp()
            throws SftpException {
        setDir(PathTools.PARENT_PATH);
    }

    public Enumeration getDirListing(String remoteDirectory, String regex)
            throws SftpException {
        assertConnected();

        try {
            String remotePath = resolveRemotePathFor(remoteDirectory);
            List<PathInfo> entries = this.service.listDirectory(remotePath);
            Collections.sort(entries, new PathInfo.NaturalComparator());
            Vector<SftpFile> ret = new Vector<>();

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

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

    /**
     * Gets directory listing from SSH server where file names matches
     * the regular expression.
     * <p/>
     * For example, to list all files with extension .java or .class the regex
     * value should be ".+\\.(?:java|class)".
     *
     * @param regex Regular expression used to obtain the file list
     *
     * @return file list which matches the regular expression.
     *
     * @throws SftpException If an error occurs.
     */
    public Enumeration getDirListing(String regex)
            throws SftpException {
        return getDirListing(this.currentRemoteDirectory, regex);
    }

    /**
     * Gets directory listing from SSH server.
     *
     * @return an <code>Enumeration</code> of <code>SftpFile</code> objects
     *
     * @throws SftpException if an I/O or SSH error occurs.
     * @see #getDirListingAsString()
     */
    public synchronized Enumeration getDirListing()
            throws SftpException {
        return getDirListing(".+");
    }

    /**
     * Gets directory listing from SSH server where file names matches
     * the regular expression.
     *
     * @param regex regular expression used to obtain the file list
     *
     * @return a directory listing string
     *
     * @throws SftpException if an I/O or SSH error occurs.
     */
    public synchronized String getDirListingAsString(String regex)
            throws SftpException {
        Enumeration entries = getDirListing(regex);
        StringBuilder buffer = new StringBuilder();
        int i = 0;

        while (entries.hasMoreElements()) {
            SftpFile entry = (SftpFile) entries.nextElement();
            buffer.append(i++ > 0 ? "\r\n" : "").append(entry.getFilename()); // todo
        }

        return buffer.toString();
    }

    /**
     * Gets directory listing of files on SSH server.
     *
     * @return a directory listing
     *
     * @throws SftpException if an I/O or SSH error occurs.
     */
    public synchronized String getDirListingAsString()
            throws SftpException {
        return getDirListingAsString(".+");
    }

    /**
     * Gets the remote directory content names.
     *
     * @param regex regular expression used to obtain the file list
     *
     * @return an <code>Enumeration</code> of <code>String</code> containing filenames
     *
     * @throws SftpException if an I/O or SSH error occurs.
     */
    public synchronized Enumeration getNameListing(String regex)
            throws SftpException {
        Enumeration entries = getDirListing(regex);
        Vector<String> ret = new Vector<>();

        while (entries.hasMoreElements()) {
            SftpFile entry = (SftpFile) entries.nextElement();
            ret.add(entry.getFilename());
        }

        return ret.elements();
    }

    /**
     * Gets directory listing of files on SSH server.
     *
     * @return an <code>Enumeration</code> of <code>String</code> where each item represents a file
     *         or directory name
     *
     * @throws SftpException if an I/O or SSH error occurs.
     */
    public synchronized Enumeration getNameListing()
            throws SftpException {
        return getNameListing(".+");
    }

    /**
     * Makes directory on SSH server.
     *
     * @param remoteDirectory name of directory to make
     *
     * @throws SftpException if an I/O or SSH error occurs.
     */
    public synchronized void makeDir(String remoteDirectory)
            throws SftpException {
        assertConnected();
        
        try {
            String remotePath = resolveRemotePathFor(remoteDirectory);
            this.service.createDirectory(remotePath, this.defaultDirectoryPermissions);
            raiseEvent(new SftpCreateDirEvent(this, remotePath, this.currentRemoteDirectory));
        } catch (Exception e) {
            throw wrap(e);
        }
    }

    /**
     * Makes directory on SSH server, creating subdirectories if necessary.
     *
     * @param remoteDirectory full path of directory to make
     *
     * @throws SftpException if an I/O or SSH error occurs
     */
    public synchronized void makeDirRecursive(String remoteDirectory)
            throws SftpException {
        assertConnected();

        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, this.defaultDirectoryPermissions);
                        raiseEvent(new SftpCreateDirEvent(this, currentPath, this.currentRemoteDirectory));
                    }
                }
            }
        } catch (Exception e) {
            throw wrap(e);
        }
    }

    /**
     * Deletes directory from SSH server.
     *
     * @param remoteDirectory the directory to delete
     *
     * @throws SftpException if an I/O or SSH error occurs.
     */
    public synchronized void deleteDir(String remoteDirectory)
            throws SftpException {
        assertConnected();

        try {
            String remotePath = resolveRemotePathFor(remoteDirectory);
            this.service.removeDirectory(remotePath);
            raiseEvent(new SftpDeleteDirEvent(this, remoteDirectory, this.currentRemoteDirectory));
        } catch (Exception e) {
            throw wrap(e);
        }
    }

    /**
     * Deletes directory from SSH server recursively.
     *
     * @param remoteDirectory the directory to delete.
     * @param recursive         <code>true</code> if directory contents should be deleted recursively
     *
     * @throws SftpException if an I/O or SSH error occurs.
     */
    public synchronized void deleteDir(String remoteDirectory, boolean recursive)
            throws SftpException {
        assertConnected();

        if (recursive) {
            new RecursiveDeleteOperation(remoteDirectory).applyTo(this);
        } else {
            deleteDir(remoteDirectory);
        }
    }

    /**
     * Gets a remote file list fron the current directory.
     *
     * @return A collection of file paths.
     *
     * @throws SftpException If a IO error occurs
     */
    public synchronized Vector getRemoteFileList(String remoteDirectory)
            throws SftpException {
        return new Vector<SftpFile>(new RecursiveFileListOperation(remoteDirectory).applyTo(this).files());
    }

    /**
     * Get size of all files rescursively in a given remote directory.
     *
     * @param remoteDirectory - the remote directory name
     *
     * @return size of files.
     */
    public synchronized int getRecursiveDirectoryFileCount(String remoteDirectory)
            throws SftpException {
        Enumeration entries = getRemoteFileList(remoteDirectory).elements();
        int count = 0;

        while (entries.hasMoreElements()) {
            SftpFile entry = (SftpFile) entries.nextElement();
            if (!entry.isDirectory()) {
                ++count;
            }
        }

        return count;
    }

    /**
     * Get size of all files recursively in a given remote directory.
     *
     * @param remoteDirectory - the remote directory name
     *
     * @return size of files.
     */
    public synchronized long getRecursiveDirectorySize(String remoteDirectory)
            throws SftpException {
        Enumeration entries = getRemoteFileList(remoteDirectory).elements();
        long size = 0L;

        while (entries.hasMoreElements()) {
            SftpFile entry = (SftpFile) entries.nextElement();
            size += entry.getFilesize();
        }

        return size;
    }

    /**
     * Gets real server path.
     *
     * @param remotePath path to resolve
     *
     * @return real server path
     *
     * @throws SftpException if an error occurs
     */
    public String getRealPath(String remotePath)
            throws SftpException {
        assertConnected();

        try {
            PathInfo pathInfo = this.service.infoAbout(remotePath, false);
            return pathInfo.name;
        } catch (Exception e) {
            throw wrap(e);
        }
    }

    /**
     * Checks if path is valid, i.e. denotes existing file or directory.
     *
     * @param remotePath path to check
     *
     * @return <code>true</code> if path is valid;
     *         <code>false</code> otherwise
     *
     * @throws SftpException if an error occurs
     */
    public synchronized boolean exists(String remotePath)
            throws SftpException {
        assertConnected();

        try {
            String absoluteRemotePath = resolveRemotePathFor(remotePath);
            return this.service.exists(absoluteRemotePath);
        } catch (Exception e) {
            throw wrap(e);
        }
    }

    /**
     * Checks if remote pathname denotes a directory.
     *
     * @param remoteDirectory remote pathname
     *
     * @return <code>true</code> if remote pathname denotes a directory; <code>false</code> otherwise
     *
     * @throws SftpException if an I/O or SSH error occurs.
     */
    public synchronized boolean isDirectory(String remoteDirectory)
            throws SftpException {
        UnixFileType type = getType(remoteDirectory);
        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 SftpException if an error occurs
     */
    public synchronized UnixFileType getType(String remotePath)
            throws SftpException {
        assertConnected();

        try {
            String path = resolveRemotePathFor(remotePath);
            return this.service.typeOf(path, false);
        } catch (Exception e) {
            throw wrap(e);
        }
    }

    /**
     * Returns remote symbolic link target path.
     *
     * @param remoteFile the remote link file
     * @return remote link target path
     *
     * @throws SftpException if an error occurs
     */
    public synchronized String getSymbolicLinkTargetPath(String remoteFile)
            throws SftpException {
        assertConnected();

        try {
            String remotePath = resolveRemotePathFor(remoteFile);
            PathInfo pathInfo = this.service.infoAbout(remotePath, true);

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

    /**
     * Gets filesize of remote file from SSH server.
     *
     * @param remoteFile the remote file
     *
     * @return filesize in bytes
     *
     * @throws SftpException if an I/O or SSH error occurs.
     */
    public synchronized long getFilesize(String remoteFile)
            throws SftpException {
        assertConnected();

        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 SftpException if an I/O or SSH error occurs.
     */
    public synchronized Date getFileCreationTimestamp(String remoteFile)
            throws SftpException {
        assertConnected();

        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 SftpException if an I/O or SSH error occurs.
     */
    public void setFileCreationTimestamp(String remoteFile, Date timestamp)
            throws SftpException {
        assertConnected();

        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 SftpException if an I/O or SSH error occurs.
     */
    public synchronized Date getFileAccessTimestamp(String remoteFile)
            throws SftpException {
        assertConnected();

        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 SftpException if an I/O or SSH error occurs.
     */
    public void setFileAccessTimestamp(String remoteFile, Date timestamp)
            throws SftpException {
        assertConnected();

        try {
            String remotePath = resolveRemotePathFor(remoteFile);
            this.service.accessTime(remotePath, timestamp.getTime());
        } catch (Exception e) {
            throw wrap(e);
        }
    }

    /**
     * Gets modification date/timestamp of remote file from SSH server.
     *
     * @param remoteFile the remote filename
     *
     * @return the modification date/timestamp of remote file
     *
     * @throws SftpException if an I/O or SSH error occurs.
     */
    public synchronized Date getFileTimestamp(String remoteFile)
            throws SftpException {
        assertConnected();

        try {
            String remotePath = resolveRemotePathFor(remoteFile);
            long modificationTime = this.service.modificationTimeOf(remotePath, true);

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

    /**
     * Sets file modification time.
     *
     * @param remoteFile       the remote file name
     * @param timestamp the file modification time
     *
     * @throws SftpException if an I/O or SSH error occurs.
     */
    public void setFileTimestamp(String remoteFile, Date timestamp)
            throws SftpException {
        assertConnected();

        try {
            String remotePath = resolveRemotePathFor(remoteFile);
            this.service.modificationTime(remotePath, timestamp.getTime());
        } 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 SftpException if an I/O or SSH error occurs.
     */
    public synchronized int getFilePermissions(String remoteFile)
            throws SftpException {
        assertConnected();

        try {
            String remotePath = resolveRemotePathFor(remoteFile);
            PosixFilePermissions permissions = this.service.permissionsOf(remotePath, false);

            if (permissions == null) {
                throw new SftpException("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 SftpException if an I/O or SSH error occurs.
     */
    public synchronized void setFilePermissions(String remoteFile, int permissions)
            throws SftpException {
        assertConnected();

        try {
            String remotePath = resolveRemotePathFor(remoteFile);
            PosixFilePermissions filePermissions = PosixFilePermissionsFormat.parse(permissions);
            this.service.permissions(remotePath, filePermissions);
        } catch (Exception e) {
            throw wrap(e);
        }
    }

    public synchronized boolean sameChecksum(File localFile, String remoteFile)
            throws SftpException {
        InputStream localIn = null;

        try {
            localIn = Files.newInputStream(localFile.toPath());

            byte[] localFileHash = Checksum.MD5.of(localIn, localFile.length(), 0);
            String remotePath = resolveRemotePathFor(remoteFile);
            byte[] remoteFileHash = this.service.hashOf(remotePath, 0, getFilesize(remoteFile), 0, Checksum.MD5.code).hash;

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

    /**
     * Creates a symbolc link.
     *
     * @param remoteLinkFile a link file
     * @param remoteTargetFile a link target file
     *
     * @throws SftpException if an error occurs
     */
    public synchronized void createSymbolicLink(String remoteLinkFile, String remoteTargetFile)
            throws SftpException {
        assertConnected();

        try {
            String linkPath = resolveRemotePathFor(remoteLinkFile);
            String targetPath = resolveRemotePathFor(remoteTargetFile);
            this.service.createSymbolicLink(linkPath, targetPath);
        } catch (Exception e) {
            throw wrap(e);
        }
    }

    /**
     * Renames remote file on SSH server.
     *
     * @param sourceRemoteFile name of file to rename
     * @param destinationRemoteFile    new name of file
     *
     * @throws SftpException if an I/O or SSH error occurs.
     */
    public synchronized void renameFile(String sourceRemoteFile, String destinationRemoteFile)
            throws SftpException {
        assertConnected();

        try {
            String sourcePath = resolveRemotePathFor(sourceRemoteFile);
            String destinationPath = resolveRemotePathFor(destinationRemoteFile);
            this.service.rename(sourcePath, destinationPath);
            raiseEvent(new SftpRenameFileEvent(this, sourcePath, destinationPath, this.currentRemoteDirectory));
        } catch (Exception e) {
            throw wrap(e);
        }
    }

    /**
     * Deletes file from SSH server.
     *
     * @param remoteFile the file to delete
     *
     * @throws SftpException if an I/O or SSH error occurs.
     */
    public synchronized void deleteFile(String remoteFile)
            throws SftpException {
        assertConnected();

        try {
            String remotePath = resolveRemotePathFor(remoteFile);
            this.service.removeFile(remotePath);
            raiseEvent(new SftpDeleteFileEvent(this, remotePath, this.currentRemoteDirectory));
        } catch (Exception e) {
            throw wrap(e);
        }
    }

    /**
     * Deletes multiple files matching regular expression from SSH server's current directory.
     * Regular expressions may be used to specify multiple files e.g. "<tt>.</tt><tt>*</tt><tt>\\.txt</tt>" will delete all
     * files ending in .txt extension.
     *
     * @param regex the regular expression to use
     *
     * @throws SftpException if an I/O or SSH error occurs.
     */
    public synchronized void mdelete(String regex)
            throws SftpException {
        Enumeration entries = getDirListing(regex);

        while (entries.hasMoreElements()) {
            SftpFile entry = (SftpFile) entries.nextElement();

            if (entry.isDirectory()) {
                deleteDir(entry.getFilename(), true);
            } else {
                deleteFile(entry.getFilename());
            }
        }
    }

    /**
     * Downloads file from SSH server and writes to specified <code>OutputStream</code>.
     *
     * @param out        the <code>OutputStream</code> to write file to
     * @param remoteFile the remote file to download
     * @param remoteFileOffset        the offset into the remote file in bytes
     *
     * @throws SftpException if an I/O or SSH error occurs.
     */
    public synchronized void download(OutputStream out, String remoteFile, long remoteFileOffset)
            throws SftpException {
        assertConnected();

        try {
            String remotePath = resolveRemotePathFor(remoteFile);
            long remoteFileSize = getFilesize(remotePath) - remoteFileOffset;
            boolean textModeRequired = this.mode == ASCII;
            CountingOutputStream countingOut = new CountingOutputStream(textModeRequired && this.service instanceof SftpFileService3 && this.endOfLine != null && !this.endOfLine.isEmpty() ? EolFilterOutputStream.commonEolFilterStream(out, this.endOfLine.getBytes(StandardCharsets.US_ASCII)) : out);
            FileService.TransferListener listener = downloadListener(remoteFile,
                                                                     this.currentRemoteDirectory,
                                                                     "",
                                                                     0,
                                                                     remoteFileSize);
            long startTime = Clock.now();
            if (this.pipelinedTransferEnabled) {
                this.service.readFile(remotePath, 0, countingOut, textModeRequired, listener, this.executorService);
            } else {
                this.service.readFile(remotePath, 0, countingOut, textModeRequired, listener);
            }
            raiseDownloadEvent(remoteFile, "", countingOut.writtenBytes(), Clock.elapsedSince(startTime));
        } catch (Exception e) {
            raiseDownloadErrorEvent(remoteFile, "");
            throw wrap(e);
        }
    }

    /**
     * Downloads file from SSH server and writes to specified <code>OutputStream</code>.
     *
     * @param out        the <code>OutputStream</code> to write file to
     * @param remoteFile the remote file to download
     *
     * @throws SftpException if an I/O or SSH error occurs.
     */
    public synchronized void download(OutputStream out, String remoteFile)
            throws SftpException {
        download(out, remoteFile, 0);
    }

    /**
     * Downloads the file from SSH server.
     *
     * @param localFile  the local file to download
     * @param remoteFile the remote file to download
     *
     * @return the file downloaded
     *
     * @throws SftpException if an I/O or SSH error occurs.
     */
    public synchronized File download(String localFile, String remoteFile)
            throws SftpException {
        return resumeDownload(localFile, remoteFile, 0);
    }

    /**
     * Downloads file from SSH server.
     *
     * @param remoteFile the remote file to download
     *
     * @return the file downloaded
     *
     * @throws SftpException if an I/O or SSH error occurs.
     */
    public synchronized File download(String remoteFile)
            throws SftpException {
        return download(remoteFile, remoteFile);
    }

    /**
     * Resumes download of file from SSH server.  Downloads are stored relative to current local directory.
     *
     * @param localFile  local file name
     * @param remoteFile the file to download
     * @param remoteFileOffset        file offset in bytes
     *
     * @return file downloaded
     *
     * @throws SftpException if an I/O or SSH error occurs.
     * @see #setLocalDir(File)
     */
    public synchronized File resumeDownload(String localFile, String remoteFile, long remoteFileOffset)
            throws SftpException {
        CountingOutputStream out = null;

        try {
            String remotePath = resolveRemotePathFor(remoteFile);
            long fileSize = getFilesize(remotePath) - remoteFileOffset;
            File localPath = resolveLocalPathFor(localFile);
            boolean textModeRequired = this.mode == ASCII;
            FileOutputStream base = FileOutputStream.streamFor(localPath, remoteFileOffset, true);
            out = new CountingOutputStream(textModeRequired && this.service instanceof SftpFileService3 && this.endOfLine != null && !this.endOfLine.isEmpty() ? EolFilterOutputStream.commonEolFilterStream(base, this.endOfLine.getBytes(StandardCharsets.US_ASCII)) : base);

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

            long startTime = Clock.now();

            if (this.pipelinedTransferEnabled) {
                this.service.readFile(remotePath, remoteFileOffset, out, textModeRequired, listener, this.executorService);
            } else {
                this.service.readFile(remotePath, remoteFileOffset, out, textModeRequired, listener);
            }
            raiseDownloadEvent(remoteFile, localPath.getAbsolutePath(), out.writtenBytes(), Clock.elapsedSince(startTime));

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

    public void resumeDownload(OutputStream out, String remoteFile, long remoteFileOffset)
            throws SftpException {
        try {
            String remotePath = resolveRemotePathFor(remoteFile);
            long length = getFilesize(remotePath) - remoteFileOffset;
            boolean textModeRequired = this.mode == ASCII;
            CountingOutputStream countingOut = new CountingOutputStream(textModeRequired && this.service instanceof SftpFileService3 && this.endOfLine != null && !this.endOfLine.isEmpty() ? EolFilterOutputStream.commonEolFilterStream(out, this.endOfLine.getBytes(StandardCharsets.US_ASCII)) : out);

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

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

    /**
     * Resumes download of file from SSH server.  Downloads are stored relative to current local directory.
     *
     * @param remoteFile the file to download
     * @param remoteFileOffset        file offset in bytes
     *
     * @return the file downloaded
     *
     * @throws SftpException if an I/O or SSH error occurs.
     * @see #setLocalDir(File)
     */
    public synchronized File resumeDownload(String remoteFile, long remoteFileOffset)
            throws SftpException {
        return resumeDownload(remoteFile, remoteFile, remoteFileOffset);
    }

    /**
     * Downloads specified files in current working remote directory from SSH
     * server to local directory. Do not use this method to download a directory,
     * see <code>downloadDir(String)</code> method.
     * Downloads are stored relative to current local directory.
     *
     * @param remoteFiles filenames of files to download
     *
     * @throws SftpException if an I/O or SSH error occurs.
     * @see #downloadDir(String)
     * @see #setLocalDir(File)
     */
    public synchronized void mdownload(Enumeration remoteFiles)
            throws SftpException {
        while (remoteFiles.hasMoreElements() && !this.interrupted) {
            String remoteFile = (String) remoteFiles.nextElement();
            download(remoteFile);
        }
    }

    /**
     * Downloads multiple files matching filter from SSH server's current
     * directory. Downloads are stored relative to current local directory.
     * Valid filters may indicate that a file name ends with a or starts with
     * a given string.
     * <p/>
     * For example to download all files ending with the extension ".txt" use
     * the filter "<tt>.</tt><tt>*</tt><tt>\\.txt</tt>".  To download all files in the remote directory use
     * the filter ".+".  To download all files that start with the string "file"
     * use the filter "file.*".
     * <p/>
     * Only files matching the filter criteria in current remote directory will
     * be considered. To download a directory use the <code>downloadDir</code> method.
     *
     * @param regex the filter to use
     *
     * @throws SftpException if an I/O or SSH error occurs.
     * @see #downloadDir
     * @see #setLocalDir(File)
     */
    public synchronized void mdownload(String regex)
            throws SftpException {
        Enumeration entries = getDirListing(regex);

        while (entries.hasMoreElements() && !this.interrupted) {
            SftpFile entry = (SftpFile) entries.nextElement();

            if (!entry.isDirectory()) {
                download(entry.getFilename());
            }
        }
    }

    /**
     * Downloads remote directory and contents from SSH server.
     * Downloads are stored relative to current local directory.
     *
     * @param remoteDirectory     the directory to download
     * @param retryAttempts    The retry limit number
     * @param retryInterval the retry interval in seconds
     * @param threadCount   the number of threads.
     *
     * @throws SftpException if an I/O or SSH error occurs.
     * @see #setLocalDir
     */
    public synchronized void downloadDir(String remoteDirectory, int retryAttempts, int retryInterval, int threadCount)
            throws SftpException {
        this.downloadDirectoryOperation = new DownloadDirectoryOperation(resolveRemotePathFor(remoteDirectory),
                                                                         false,
                                                                         Math.max(retryAttempts + 1, 1),
                                                                         Time.seconds(retryInterval),
                                                                         "/",
                                                                         threadCount > 0 ? threadCount : null);
        this.downloadDirectoryOperation.applyTo(this);
    }

    /**
     * Downloads remote directory and contents from SSH server.
     * Downloads are stored relative to current local directory.
     *
     * @param remoteDirectory     the directory to download
     * @param retryAttempts    The retry limit number
     * @param retryInterval the retry interval in seconds
     *
     * @throws SftpException if an I/O or SSH error occurs.
     * @see #setLocalDir
     */
    public synchronized void downloadDir(String remoteDirectory, int retryAttempts, int retryInterval)
            throws SftpException {
        downloadDir(remoteDirectory, retryAttempts, retryInterval, 0);
    }

    /**
     * Downloads remote directory and contents from SSH server.
     * Downloads are stored relative to current local directory.
     *
     * @param remoteDirectory  the directory to download
     * @param retryAttempts The retry limit number
     *
     * @throws SftpException if an I/O or SSH error occurs.
     * @see #setLocalDir
     */
    public synchronized void downloadDir(String remoteDirectory, int retryAttempts)
            throws SftpException {
        downloadDir(remoteDirectory, retryAttempts, 0);
    }

    /**
     * Downloads remote directory and contents recursively from SSH server.
     * Downloads are stored relative to current local directory.
     *
     * @param remoteDirectory the directory to download
     *
     * @throws SftpException if an I/O or SSH error occurs.
     * @see #setLocalDir(File)
     */
    public synchronized void downloadDir(String remoteDirectory)
            throws SftpException {
        downloadDir(remoteDirectory, 0);
    }

    /**
     * Uploads file to SSH server.
     * Destination filename should NOT contain any path information.
     * Destination file will be stored relative to current remote directory.
     *
     * @param in      the input stream to read from
     * @param remoteFile the filename to store file as on SSH server
     * @param shouldAppend     <code>true</code> to append to remote file, <code>false</code> otherwise
     *
     * @throws SftpException if an I/O or SSH error occurs
     */
    public synchronized void upload(InputStream in, String remoteFile, boolean shouldAppend)
            throws SftpException {
        try {
            String remotePath = resolveRemotePathFor(remoteFile);
            boolean textModeRequired = this.mode == ASCII;
            CountingInputStream countingIn = new CountingInputStream(textModeRequired && this.service instanceof SftpFileService3 && this.endOfLine != null && !this.endOfLine.isEmpty() ? EolFilterInputStream.commonEolFilterStream(in, this.endOfLine.getBytes(StandardCharsets.US_ASCII)) : in);
            long remoteFileOffset = shouldAppend && this.version == 3 && exists(remoteFile) ? getFilesize(remoteFile) : 0;
            FileService.TransferListener listener = uploadListener(remoteFile,
                                                                   this.currentRemoteDirectory,
                                                                   "",
                                                                   0,
                                                                   0);

            long startTime = Clock.now();

            if (shouldAppend && this.version > 3) {
                if (this.pipelinedTransferEnabled) {
                    this.service.appendFile(countingIn, remotePath, textModeRequired, listener, this.executorService);
                } else {
                    this.service.appendFile(countingIn, remotePath, textModeRequired, listener);
                }
            } else {
                if (this.pipelinedTransferEnabled) {
                    this.service.writeFile(countingIn, remotePath, remoteFileOffset, textModeRequired, listener, this.executorService);
                } else {
                    this.service.writeFile(countingIn, remotePath, remoteFileOffset, textModeRequired, listener);
                }
            }

            raiseUploadEvent("", remoteFile, countingIn.readBytes(), Clock.elapsedSince(startTime));
        } catch (Exception e) {
            raiseUploadErrorEvent("", remoteFile);
            throw wrap(e);
        }
    }

    /**
     * Uploads file to SSH server.
     * Destination filename should NOT contain any path information.
     * Destination file will be stored relative to current remote directory.
     *
     * @param input      the input stream to read from
     * @param remoteFile the filename to store file as on SSH server
     *
     * @throws SftpException if an I/O or SSH error occurs
     */
    public synchronized void upload(InputStream input, String remoteFile)
            throws SftpException {
        upload(input, remoteFile, false);
    }

    /**
     * Uploads file to SSH server where contents of file is not on disk but
     * in memory.
     *
     * @param data       the raw contents of the file to upload
     * @param remoteFile the filename to store file as on SSH server
     * @param shouldAppend     <code>true</code> to appending to remote file
     *                   on SSH server; <code>false</code> otherwise
     *
     * @throws SftpException if an I/O or SSH error occurs.
     */
    public synchronized void upload(byte[] data, String remoteFile, boolean shouldAppend)
            throws SftpException {
        upload(new ByteArrayInputStream(data), remoteFile, shouldAppend);
    }

    /**
     * Uploads file to SSH server where contents of file is not on disk but
     * in memory.
     *
     * @param data       the raw contents of the file to upload
     * @param remoteFile the filename to store file as on SSH server
     *
     * @throws SftpException if an I/O or SSH error occurs.
     */
    public synchronized void upload(byte[] data, String remoteFile)
            throws SftpException {
        upload(data, remoteFile, false);
    }

    /**
     * Uploads file to SSH server.
     * Uploads are performed relative to current local directory.
     *
     * @param localFile  the local file to upload
     * @param remoteFile the destination of upload
     * @param shouldAppend     <code>true</code> to appending to remote file
     *                   on SSH server, <code>false</code> otherwise
     *
     * @throws SftpException if an I/O or SSH error occurs.
     */
    public synchronized void upload(File localFile, String remoteFile, boolean shouldAppend)
            throws SftpException {
        CountingInputStream in = null;

        try {
            String remotePath = resolveRemotePathFor(remoteFile);
            long localFileSize = localFile.length();
            long remoteFileOffset = shouldAppend && this.version == 3 && exists(remoteFile) ? getFilesize(remoteFile) : 0;
            boolean textModeRequired = this.mode == ASCII;
            FileInputStream base = FileInputStream.streamFor(localFile, 0);
            in = new CountingInputStream(textModeRequired && this.service instanceof SftpFileService3 && this.endOfLine != null && !this.endOfLine.isEmpty() ? EolFilterInputStream.commonEolFilterStream(base, this.endOfLine.getBytes(StandardCharsets.US_ASCII)) : base);

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

            long startTime = Clock.now();

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

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

    /**
     * Uploads file to SSH server.
     * If <code>localFile</code> is a directory then directory and entire contents
     * are uploaded.
     *
     * @param localFile  the local file to upload
     * @param remoteFile the destination of upload
     *
     * @throws SftpException if an I/O or SSH error occurs.
     */
    public synchronized void upload(File localFile, String remoteFile)
            throws SftpException {
        upload(localFile, remoteFile, false);
    }

    /**
     * Uploads file to SSH server.
     *
     * @param localFile the local file to upload
     * @param append    <code>true</code> to appending to remote file
     *                  on SSH server, <code>false</code> otherwise
     *
     * @throws SftpException if an I/O or SSH error occurs.
     */
    public synchronized void upload(File localFile, boolean append)
            throws SftpException {
        upload(localFile, localFile.getName(), append);
    }

    /**
     * Uploads file to SSH server.
     * If <code>localFile</code> is a directory then directory and entire contents
     * are uploaded.
     *
     * @param localFile the local file to upload
     *
     * @throws SftpException if an I/O or SSH error occurs.
     */
    public synchronized void upload(File localFile)
            throws SftpException {
        upload(localFile, false);
    }

    /**
     * Uploads a file to SSH server. File name will be uploaded with the extension
     * specified by the argument and after the file has been uploaded, the file is renamed.
     *
     * @param tempFileExtension The extension will be used
     * @param localFile The local file to upload
     *
     * @throws SftpException if an I/O or SSH related error occurs
     */
    public synchronized void upload(String tempFileExtension, File localFile)
            throws SftpException {
        String remoteFile = localFile.getName() + tempFileExtension;
        upload(localFile, remoteFile);
        renameFile(remoteFile, localFile.getName());
    }

    /**
     * Uploads file to SSH server.
     * Uploads are performed relative to current local directory. Destination filename should
     * not contain any path information.  Destination file will be stored relative to current remote directory.
     *
     * @param localFile  the local file to upload
     * @param remoteFile the destination filename of upload
     * @param shouldAppend     <code>true</code> to appending to remote file
     *                   on SSH server, <code>false</code> otherwise
     *
     * @throws SftpException if an I/O or SSH error occurs.
     * @see #setLocalDir(File)
     */
    public synchronized void upload(String localFile, String remoteFile, boolean shouldAppend)
            throws SftpException {
        File source = resolveLocalPathFor(localFile);
        upload(source, remoteFile, shouldAppend);
    }

    /**
     * Uploads file to SSH server.
     * Uploads are performed relative to current local directory.  Destination filename should
     * not contain any path information.  Destination file will be stored relative to current remote directory.
     *
     * @param localFile  the local file to upload
     * @param remoteFile the destination filename of upload
     *
     * @throws SftpException if an I/O or SSH error occurs.
     * @see #setLocalDir(File)
     */
    public synchronized void upload(String localFile, String remoteFile)
            throws SftpException {
        upload(localFile, remoteFile, false);
    }

    /**
     * Uploads file to SSH server.
     * Uploads are performed relative to current local directory.
     *
     * @param localFile the local file to upload
     * @param shouldAppend    <code>true</code> to appending to remote file
     *                  on SSH server, <code>false</code> otherwise
     *
     * @throws SftpException if an I/O or SSH error occurs.
     */
    public synchronized void upload(String localFile, boolean shouldAppend)
            throws SftpException {
        File source = resolveLocalPathFor(localFile);
        upload(source, source.getName(), shouldAppend);
    }

    /**
     * Uploads file to SSH server.
     * Uploads are performed relative to current local directory.
     *
     * @param localFile the local file to upload
     *
     * @throws SftpException if an I/O or SSH error occurs.
     * @see #setLocalDir(File)
     */
    public synchronized void upload(String localFile)
            throws SftpException {
        upload(localFile, false);
    }

    /**
     * Resumes upload of file to SSH server.
     *
     * @param localFile  the local file to upload
     * @param remoteFile the destination remote file
     * @param localFileOffset        the local file offset in bytes
     *
     * @throws SftpException if an I/O or SSH related error occurs
     */
    public synchronized void resumeUpload(File localFile, String remoteFile, long localFileOffset)
            throws SftpException {
        CountingInputStream in = null;

        try {
            String remotePath = resolveRemotePathFor(remoteFile);
            long localFileSize = localFile.length() - localFileOffset;
            boolean textModeRequired = this.mode == ASCII;
            FileInputStream base = FileInputStream.streamFor(localFile, localFileOffset);
            in = new CountingInputStream(textModeRequired && this.service instanceof SftpFileService3 && this.endOfLine != null && !this.endOfLine.isEmpty() ? EolFilterInputStream.commonEolFilterStream(base, this.endOfLine.getBytes(StandardCharsets.US_ASCII)) : base);

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

            long startTime = Clock.now();

            if (this.pipelinedTransferEnabled) {
                this.service.writeFile(in, remotePath, localFileOffset, textModeRequired, listener, this.executorService);
            } else {
                this.service.writeFile(in, remotePath, localFileOffset, textModeRequired, listener);
            }
            raiseUploadEvent(localFile.getAbsolutePath(), remoteFile, in.readBytes(), Clock.elapsedSince(startTime));
        } catch (Exception e) {
            raiseUploadErrorEvent(localFile.getAbsolutePath(), remoteFile);
            throw wrap(e);
        } finally {
            IOTools.close(in);
        }
    }

    public void resumeUpload(InputStream in, long length, String remoteFile, long remoteFileOffset)
            throws SftpException {
        try {
            String remotePath = resolveRemotePathFor(remoteFile);
            boolean textModeRequired = this.mode == ASCII;
            CountingInputStream countingIn = new CountingInputStream(textModeRequired && this.service instanceof SftpFileService3 && this.endOfLine != null && !this.endOfLine.isEmpty() ? EolFilterInputStream.commonEolFilterStream(in, this.endOfLine.getBytes(StandardCharsets.US_ASCII)) : in);
            FileService.TransferListener listener = uploadListener(remoteFile,
                                                                   this.currentRemoteDirectory,
                                                                   "",
                                                                   remoteFileOffset,
                                                                   length);

            long startTime = Clock.now();

            if (this.pipelinedTransferEnabled) {
                this.service.writeFile(countingIn, remotePath, remoteFileOffset, textModeRequired, listener, this.executorService);
            } else {
                this.service.writeFile(countingIn, remotePath, remoteFileOffset, textModeRequired, listener);
            }

            raiseUploadEvent("", remoteFile, countingIn.readBytes(), Clock.elapsedSince(startTime));
        } catch (Exception e) {
            raiseUploadErrorEvent("", remoteFile);
            throw wrap(e);
        }
    }

    /**
     * Resumes upload of file to SSH server.
     *
     * @param localFile the local file to upload
     * @param localFileOffset       file offset in bytes
     *
     * @throws SftpException if an I/O or SSH error occurs.
     */
    public synchronized void resumeUpload(File localFile, long localFileOffset)
            throws SftpException {
        resumeUpload(localFile, localFile.getName(), localFileOffset);
    }

    /**
     * Resumes upload of file to SSH server. Uploads are relative to current local directory.
     *
     * @param localFile  the local file to upload
     * @param remoteFile the remote destination file
     * @param localFileOffset        the local file offset in bytes
     *
     * @throws SftpException if an I/O or SSH error occurs
     * @see #setLocalDir(File)
     */
    public synchronized void resumeUpload(String localFile, String remoteFile, long localFileOffset)
            throws SftpException {
        File source = resolveLocalPathFor(localFile);
        resumeUpload(source, remoteFile, localFileOffset);
    }

    /**
     * Resumes upload of file to SSH server.  Uploads are relative to current local directory.
     *
     * @param localFile the local file to upload
     * @param localFileOffset       file offset in bytes
     *
     * @throws SftpException if an I/O or SSH error occurs.
     * @see #setLocalDir(File)
     */
    public synchronized void resumeUpload(String localFile, long localFileOffset)
            throws SftpException {
        File source = resolveLocalPathFor(localFile);
        resumeUpload(source, localFileOffset);
    }

    /**
     * Uploads multiple files to SSH server.
     * Uploads are performed relative to current local directory.
     *
     * @param localFiles filenames of files to upload
     *
     * @throws SftpException if an I/O or SSH error occurs.
     */
    public synchronized void mupload(Enumeration localFiles)
            throws SftpException {
        while (localFiles.hasMoreElements() && !this.interrupted) {
            String filename = (String) localFiles.nextElement();
            upload(filename);
        }
    }

    /**
     * Uploads multiple files matching filter to SSH server's current directory.
     * Uploads are performed relative to current local directory. Regular expressions
     * may be used to specify multiple files e.g. "<tt>.</tt><tt>*</tt><tt>\\.txt</tt>" may be used to upload all files
     * with a .txt extension.
     *
     * @param regex the regular expression to use
     *
     * @throws SftpException if an I/O or SSH error occurs.
     */
    public synchronized void mupload(String regex)
            throws SftpException {
        Enumeration entries = getLocalDirListing(regex);

        while (entries.hasMoreElements() && !this.interrupted) {
            File entry = (File) entries.nextElement();

            if (!entry.isDirectory()) {
                upload(entry);
            }
        }
    }

    /**
     * Uploads local directory and contents.
     * Uploads are stored relative to current remote directory.
     *
     * @param localDirectory      the directory to upload
     * @param retryAttempts    number of times to retry if upload fails
     * @param retryInterval retry interval in seconds
     * @param tempFileExtension     the temporary extension which will be used to upload the files or <code>null</code> for default extension
     * @param threadCount   the upload thread count.
     *
     * @throws SftpException if an I/O or SSH error occurs.
     */
    public synchronized void uploadDir(File localDirectory, int retryAttempts, int retryInterval, String tempFileExtension, int threadCount)
            throws SftpException {
        this.uploadDirectoryOperation = new UploadDirectoryOperation(localDirectory,
                                                                     tempFileExtension,
                                                                     false,
                                                                     Math.max(retryAttempts + 1, 1),
                                                                     Time.seconds(retryInterval),
                                                                     threadCount > 0 ? threadCount : null);
        this.uploadDirectoryOperation.applyTo(this);
    }

    /**
     * Uploads local directory and contents.
     * Uploads are stored relative to current remote directory.
     *
     * @param localDirectory      the directory to upload
     * @param retryAttempts    number of times to retry if upload fails
     * @param retryInterval retry interval in seconds
     * @param tempFileExtension     the temporary extension which will be used to upload the files or <code>null</code> for default extension
     *
     * @throws SftpException if an I/O or SSH error occurs.
     */
    public synchronized void uploadDir(File localDirectory, int retryAttempts, int retryInterval, String tempFileExtension)
            throws SftpException {
        uploadDir(localDirectory, retryAttempts, retryInterval, tempFileExtension, 0);
    }

    /**
     * Uploads local directory and contents.
     * Uploads are stored relative to current remote directory.
     *
     * @param localDirectory    the directory to upload
     * @param retryAttempts  number of times to retry if upload fails
     * @param tempFileExtension   the temporary extension which will be used to upload the files or <code>null</code> for default extension
     * @param threadCount the number of new threads to spawn for upload.
     *
     * @throws SftpException if an I/O or SSH error occurs.
     */
    public synchronized void uploadDir(File localDirectory, int retryAttempts, String tempFileExtension, int threadCount)
            throws SftpException {
        uploadDir(localDirectory, retryAttempts, 0, tempFileExtension, threadCount);
    }

    /**
     * Uploads local directory and contents.
     * Uploads are stored relative to current remote directory.
     *
     * @param localDirectory   the directory to upload
     * @param retryAttempts number of times to retry if upload fails
     * @param tempFileExtension  the temporary extension which will be used to upload the files or <code>null</code> for default extension
     *
     * @throws SftpException if an I/O or SSH error occurs.
     */
    public synchronized void uploadDir(File localDirectory, int retryAttempts, String tempFileExtension)
            throws SftpException {
        uploadDir(localDirectory, retryAttempts, tempFileExtension, 0);
    }

    /**
     * Uploads local directory and contents recursively to SSH server.
     * Uploads are stored relative to current remote directory.
     *
     * @param localDirectory the directory to upload
     * @param threadCount    the number of threads to spawn for upload
     * @param retryAttempts     number of times to retry if upload fails
     *
     * @throws SftpException if an I/O or SSH error occurs.
     */
    public synchronized void uploadDir(File localDirectory, int retryAttempts, int threadCount)
            throws SftpException {
        uploadDir(localDirectory, retryAttempts, null, threadCount);
    }


    /**
     * Uploads local directory and contents.
     * Uploads are stored relative to current remote directory.
     *
     * @param localDirectory   the directory to upload
     * @param retryAttempts number of times to retry if upload fails
     *
     * @throws SftpException if an I/O or SSH error occurs.
     */
    public synchronized void uploadDir(File localDirectory, int retryAttempts)
            throws SftpException {
        uploadDir(localDirectory, retryAttempts, 0);
    }

    /**
     * Uploads local directory and contents recursively to SSH server.
     * Uploads are stored relative to current remote directory.
     *
     * @param localDirectory the directory to upload
     *
     * @throws SftpException if an I/O or SSH error occurs.
     */
    public synchronized void uploadDir(File localDirectory)
            throws SftpException {
        uploadDir(localDirectory, 0);
    }

    public InputStream getInputStream(String remoteFile, long remoteFileOffset)
            throws SftpException {
        try {
            SftpPipedInputStream in = SftpPipedInputStream.streamFor(this, remoteFile, remoteFileOffset);
            this.executorService.submit(in);

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

    public OutputStream getOutputStream(String remoteFile, long remoteFileOffset, boolean append)
            throws SftpException {
        if (append) {
            remoteFileOffset = getFilesize(remoteFile);
        }

        try {
            SftpPipedOutputStream out = SftpPipedOutputStream.streamFor(this, remoteFile, remoteFileOffset, 0);
            this.executorService.submit(out);

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

    /**
     * Interrupts the current data transfer and sets interrupted flag to <code>true</code>.
     * After invoking this method you must reset the interrupted flag to <code>false</code>
     * before transferring any files.
     *
     * @see #reset
     */
    public void interrupt() {
        this.interrupted = true;
    }

    /**
     * Checks if transfer process was interrupted.
     *
     * @return <code>true</code> if transfer process was interrupted;
     *         <code>false</code> otherwise
     */
    public boolean interrupted() {
        return this.interrupted;
    }

    /**
     * Resets the interrupted flag to <code>false</code>.
     * This method should be invoked if you have interrupted a transfer
     * using the <code>Sftp#interrupt</code> method and wish to resume transfering files.
     *
     * @see #interrupt
     */
    public void reset() {
        this.interrupted = false;
    }

    /**
     * Abort download threads.
     */
    public void abortDownloadThreads() {
        DownloadDirectoryOperation operation = this.downloadDirectoryOperation;

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

    /**
     * Cancel the given file transfer thread.
     *
     * @param remoteFile
     */
    public void abortDownloadThread(String remoteFile) {
        DownloadDirectoryOperation operation = this.downloadDirectoryOperation;

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

    /**
     * Abort all upload threads.
     */
    public void abortUploadThreads() {
        UploadDirectoryOperation operation = this.uploadDirectoryOperation;

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

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

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

    /**
     * Returns a copy of current instance.
     *
     * @return a copy of current instance
     */
    public Sftp copy() {
        Sftp that = new Sftp(this.parameters,
                             this.configuration,
                             this.version,
                             this.currentLocalDirectory.toFile(),
                             this.currentRemoteDirectory,
                             this.downloadBufferSizeBytes,
                             this.pipelineWindowSize,
                             this.logger);
        that.mode = this.mode;

        return that;
    }

    private void assertLicenseValid() {
        
    }

    private void startService()
            throws Exception {
        try {
            initService();
            this.service.start(null);
        } catch (Service.ServiceException e) {
            if (unsupportedVersionError(e) && this.automaticVersionLoweringEnabled) {
                startService3();
            } else {
                throw e;
            }
        }
    }

    private boolean unsupportedVersionError(Service.ServiceException e) {
        return e instanceof FileService.UnsupportedVersionException && ((FileService.UnsupportedVersionException) e).serverVersion == 3;
    }

    private void startService3()
            throws Exception {
        this.version = 3;
        initService();
        this.service.start(null);
    }

    private void initService()
            throws Exception {
        Connector<SyncRawConnection> rawConnector = rawConnector();
        ClientAuthentication authentication = authentication();
        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.parameters.getHostKeyVerifier(),
                                                                      authentication,
                                                                      this.configuration.sshConfiguration.initialWindowSize,
                                                                      this.configuration.sshConfiguration.maxPacketSize,
                                                                      this.logger);
        int bufferSize = Math.max(this.uploadBufferSizeBytes, this.downloadBufferSizeBytes);

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

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

    private void shutdownExecutorService() {
        if (this.executorService != null) {
            this.executorService.shutdownNow();
            this.executorService = null;
        }
    }

    private Connector<SyncRawConnection> rawConnector() {
        ConnectionDescriptor connectionDescriptor = new ConnectionDescriptor(new TransportAddress(this.parameters.getHostname(), this.parameters.getPort()), this.parameters.getConnectionTimeout());
        SocketParameters socketParameters = new SocketParameters(true, null, null, this.parameters.getSocketTrafficClass(), this.parameters.getTcpNoDelay(), null, null, Time.millis(this.parameters.getReadingTimeout()));

        if (this.parameters.getProxyType() != null && this.parameters.getProxyHost() != null) {
            ProxyDescriptor proxyDescriptor = new ProxyDescriptor(this.parameters.getProxyHost(),
                                                                  this.parameters.getProxyPort(),
                                                                  this.parameters.getProxyUsername(),
                                                                  this.parameters.getProxyPassword());
            Proxy proxy = Proxies.proxyFor(this.parameters.getProxyType());
            return new ProxySocketConnector(proxyDescriptor, proxy, connectionDescriptor, socketParameters);
        } else {
            return new SocketConnector(connectionDescriptor, socketParameters);
        }
    }

    private ClientAuthentication authentication() {
        if (this.parameters.getClientAuthentication() != null) {
            return this.parameters.getClientAuthentication();
        } else if (this.parameters.getPassword() != null && this.parameters.getKeyPair() != null) {
            return new ComponentClientAuthentication(PublicKeyClientAuthentication.defaultAuthentication(this.parameters.getUsername(), this.parameters.getPublicKeyAlgorithm(), this.parameters.getKeyPair()),
                                                     PasswordClientAuthentication.defaultAuthentication(this.parameters.getUsername(), this.parameters.getPassword()),
                                                     KeyboardInteractiveRegexClientAuthentication.defaultAuthentication(this.parameters.getUsername(), new KeyboardInteractiveRegexClientAuthentication.Entry(".*[pP][aA][sS][sS][wW][oO][rR][dD].*", this.parameters.getPassword())));
        } else if (this.parameters.getPassword() != null) {
            return new ComponentClientAuthentication(PasswordClientAuthentication.defaultAuthentication(this.parameters.getUsername(), this.parameters.getPassword()),
                                                     KeyboardInteractiveRegexClientAuthentication.defaultAuthentication(this.parameters.getUsername(), new KeyboardInteractiveRegexClientAuthentication.Entry(".*[pP][aA][sS][sS][wW][oO][rR][dD].*", this.parameters.getPassword())));
        } else if (this.parameters.getKeyPair() != null) {
            return PublicKeyClientAuthentication.defaultAuthentication(this.parameters.getUsername(), this.parameters.getPublicKeyAlgorithm(), this.parameters.getKeyPair());
        } else {
            return NoneClientAuthentication.defaultAuthentication(this.parameters.getUsername());
        }
    }

    private void assertConnected() {
        if (this.service == null || !this.service.isStarted()) {
            throw new IllegalStateException("SFTP is not connected.");
        }
    }

    private void raiseEvent(SftpEvent event) {
        for (SftpListener listener : this.listeners) {
            event.accept(listener);
        }
    }

    private SftpFile fileFor(PathInfo entry) {
        return new SftpFile(entry.name, entry.type, entry.size, entry.owner, entry.group, entry.permissions, entry.accessTime, entry.modificationTime);
    }

    private SftpException wrap(Throwable e) {
        if (e instanceof FileService.UnsupportedVersionException) {
            return new BadVersionException(((FileService.UnsupportedVersionException) e).clientVersion, ((FileService.UnsupportedVersionException) e).serverVersion);
        } else 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 UnknownHostException) {
            return new SftpUnknownHostException(e.getCause().getCause().getMessage());
        } else if (e.getCause() != null && e.getCause().getCause() instanceof ConnectException) {
            return new SftpConnectionException(e.getCause().getCause().getMessage());
        } else if (e.getCause() != null && e.getCause().getCause() instanceof ClientAuthentication.InvalidCredentialsException) {
            return new SftpAuthenticationException(this.parameters.getHostname(), this.parameters.getUsername());
        }

        // todo: add connection and timeout exceptions

        return SftpException.wrap(e);
    }

    private SftpException wrap(FileService.ServerException e) {
        if (SSH_FX_NO_SUCH_FILE == e.code) {
            return new SftpFileNotFoundException("");
        } else if (SSH_FX_PERMISSION_DENIED == e.code) {
            return new SftpPermissionDeniedException("");
        }

        return new RequestException(e.code, e.message);
    }

    private File resolveLocalPathFor(String path) {
        return this.currentLocalDirectory.resolve(path).toFile();
    }

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

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

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

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

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

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

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

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

    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 SftpProgressEvent(Sftp.this,
                                             this.fileName,
                                             this.path,
                                             this.localFilePath,
                                             this.mode,
                                             this.totalBytes,
                                             this.startBytes + transferredBytes));

        }
    }

    public static class RecursiveFileListOperation {

        private final String remoteDirectory;
        private Sftp transfer;
        private List<SftpFile> files;

        public RecursiveFileListOperation(String remoteDirectory) {
            assertNotNull(remoteDirectory);
            this.remoteDirectory = remoteDirectory;
        }

        public RecursiveFileListOperation applyTo(Sftp transfer)
                throws SftpException {
            assertNotNull(transfer);
            this.transfer = transfer;

            this.files = new LinkedList<>();
            listRecursive(this.remoteDirectory);

            return this;
        }

        public List<SftpFile> files() {
            return this.files;
        }

        private void listRecursive(String remoteDirectory)
                throws SftpException {
            String currentDirectory = this.transfer.getDir();
            this.transfer.setDir(remoteDirectory);

            Enumeration entries = this.transfer.getDirListing();

            while (entries.hasMoreElements()) {
                SftpFile entry = (SftpFile) entries.nextElement();
                String filename = entry.getFilename();

                if (entry.isDirectory() && ("..".equals(filename) || ".".equals(filename))) {
                    continue;
                }

                if (entry.isDirectory()) {
                    listRecursive(filename);
                } else {
                    this.files.add(entry);
                }
            }

            this.transfer.setDir(currentDirectory);
        }
    }

    public static class RecursiveDeleteOperation {

        private final String remoteDirectory;
        private Sftp transfer;

        public RecursiveDeleteOperation(String remoteDirectory) {
            assertNotNull(remoteDirectory);
            this.remoteDirectory = remoteDirectory;
        }

        public RecursiveDeleteOperation applyTo(Sftp transfer)
                throws SftpException {
            assertNotNull(transfer);
            this.transfer = transfer;

            deleteRecursive(this.remoteDirectory);

            return this;
        }

        private void deleteRecursive(String remoteDirectory)
                throws SftpException {
            String currentDirectory = this.transfer.getDir();
            this.transfer.setDir(remoteDirectory);

            Enumeration entries = this.transfer.getDirListing();

            while (entries.hasMoreElements()) {
                SftpFile entry = (SftpFile) entries.nextElement();
                String filename = entry.getFilename();

                if (entry.isDirectory() && ("..".equals(filename) || ".".equals(filename))) {
                    continue;
                }

                if (entry.isDirectory()) {
                    deleteRecursive(filename);
                } else {
                    this.transfer.deleteFile(filename);
                }
            }

            this.transfer.setDir(currentDirectory);
            this.transfer.deleteDir(remoteDirectory);
        }
    }

    public interface RemoteDirectory {

        RemoteDirectory applyTo(Sftp sftp)
                throws SftpException;

        @SuppressObfuscation
        class CurrentDirectory
                implements RemoteDirectory {

            public RemoteDirectory applyTo(Sftp transfer)
                    throws SftpException {
                return this;
            }
        }

        @SuppressObfuscation
        class PathDirectory
                implements RemoteDirectory {

            private final String path;

            public PathDirectory(String path) {
                this.path = path;
            }

            public RemoteDirectory applyTo(Sftp transfer)
                    throws SftpException {
                transfer.setDir(this.path);
                return this;
            }

            @Override
            public String toString() {
                return "PathDirectory {"
                       + "path='" + this.path + '\''
                       + '}';
            }
        }

        @SuppressObfuscation
        class PathElementsDirectory
                implements RemoteDirectory {

            private final List<String> pathElements;

            public PathElementsDirectory(List<String> pathElements) {
                Parameters.assertNotNull(pathElements);
                this.pathElements = pathElements;
            }

            public PathElementsDirectory applyTo(Sftp transfer)
                    throws SftpException {
                for (String element : this.pathElements) {
                    transfer.setDir(element);
                }

                return this;
            }

            @Override
            public String toString() {
                return "PathElementsDirectory {"
                       + "pathElements=" + this.pathElements
                       + '}';
            }
        }
    }

    public static class DirectoryService {

        public static Tree<Directory<File>> asTree(File directory) {
            Parameters.assertTrue(directory.isDirectory(), "Specified pathname is not a directory.");

            DirectoryEntries<File> entries = list(directory);
            Tree<Directory<File>> directoryTree = Tree.treeFor(new Directory<>(directory, entries.files));
            addSubDirectories(entries.directories, directoryTree);

            return directoryTree;
        }

        public static Tree<Directory<SftpFile>> asTree(String directoryPath, Sftp transfer)
                throws SftpException {
            String currentDirectory = transfer.getDir();
            transfer.setDir(directoryPath);

            DirectoryEntries<SftpFile> entries = list(transfer);
            SftpFile directory = new SftpFile(PathTools.nameOf(directoryPath), UnixFileType.DIRECTORY, 0, "", "", PosixFilePermissions.ownerDirectoryPermissions(), 0, Clock.now());
            Tree<Directory<SftpFile>> directoryTree = Tree.treeFor(new Directory<>(directory, entries.files));
            addSubDirectories(entries.directories, directoryTree, transfer);

            transfer.setDir(currentDirectory);

            return directoryTree;
        }

        private static DirectoryEntries<File> list(File directory) {
            Collection<File> directories = new LinkedList<>();
            Collection<File> files = new LinkedList<>();

            File[] entries = directory.listFiles();
            entries = entries != null ? entries : new File[0];

            for (File entry : entries) {
                if (entry.isDirectory()) {
                    directories.add(entry);
                } else if (entry.isFile()) {
                    files.add(entry);
                }
            }

            return new DirectoryEntries<>(directories, files);
        }

        private static DirectoryEntries<SftpFile> list(Sftp transfer)
                throws SftpException {
            Collection<SftpFile> directories = new LinkedList<>();
            Collection<SftpFile> files = new LinkedList<>();

            for (Enumeration e = transfer.getDirListing(); e.hasMoreElements(); ) {
                SftpFile entry = (SftpFile) e.nextElement();

                if (entry.isDirectory()) {
                    directories.add(entry);
                } else {
                    files.add(entry);
                }
            }

            return new DirectoryEntries<>(directories, files);
        }

        private static void addSubDirectories(Collection<File> subDirectories, Tree<Directory<File>> directoryTree) {
            for (File subDirectory : subDirectories) {
                DirectoryEntries<File> entries = list(subDirectory);
                Tree<Directory<File>> leaf = directoryTree.addLeaf(new Directory<>(subDirectory, entries.files));
                addSubDirectories(entries.directories, leaf);
            }
        }

        private static void addSubDirectories(Collection<SftpFile> subDirectories,
                                              Tree<Directory<SftpFile>> directoryTree,
                                              Sftp transfer)
                throws SftpException {
            for (SftpFile subDirectory : subDirectories) {
                transfer.setDir(subDirectory.getFilename());
                DirectoryEntries<SftpFile> entries = list(transfer);
                Tree<Directory<SftpFile>> leaf = directoryTree.addLeaf(new Directory<>(subDirectory, entries.files));
                addSubDirectories(entries.directories, leaf, transfer);
                transfer.setDirUp();
            }
        }

        private DirectoryService() {
        }

        private static class DirectoryEntries<T> {

            public final Collection<T> directories;
            public final Collection<T> files;

            private DirectoryEntries(Collection<T> directories, Collection<T> files) {
                this.directories = directories;
                this.files = files;
            }

            @Override
            public String toString() {
                return "DirectoryEntries {"
                       + "directories=" + this.directories
                       + ", files=" + this.files
                       + '}';
            }
        }

        public static class Directory<T> {

            public final T path;
            public final Collection<T> files;

            public Directory(T path, Collection<T> files) {
                this.path = path;
                this.files = files;
            }

            @Override
            public String toString() {
                return "Directory {"
                       + "path=" + this.path
                       + ", files=" + this.files
                       + '}';
            }
        }

    }

    public static class DownloadFileOperation {

        private final RemoteDirectory remoteDirectory;
        private final String remoteFile;
        private final File localFile;
        private final TransferStrategy transferStrategy;
        private final boolean fileTimestampPreservingRequired;
        private final int maxAttempts;
        private final Time attemptPeriod;
        private final boolean deleteRemoteFile;
        private final Listener listener;
        private boolean disconnectRequired;
        private Sftp transfer;
        private String currentRemoteDirectory;
        private int attempts;
        private boolean success;
        private Exception error;
        private volatile boolean cancelled;

        public DownloadFileOperation(RemoteDirectory remoteDirectory,
                                     String remoteFile,
                                     File localFile,
                                     TransferStrategy transferStrategy,
                                     boolean fileTimestampPreservingRequired,
                                     int maxAttempts,
                                     Time attemptPeriod,
                                     boolean deleteRemoteFile,
                                     Listener listener) {
            assertNotNull(remoteDirectory);
            this.remoteDirectory = remoteDirectory;

            this.remoteFile = remoteFile != null && !remoteFile.isEmpty() ? remoteFile : localFile.getName();

            assertNotNull(localFile);
            this.localFile = localFile;

            assertNotNull(transferStrategy);
            this.transferStrategy = transferStrategy;

            this.fileTimestampPreservingRequired = fileTimestampPreservingRequired;

            Parameters.assertGreater(maxAttempts, 0, "Bad max attempts value.");
            this.maxAttempts = maxAttempts;

            assertNotNull(attemptPeriod);
            this.attemptPeriod = attemptPeriod;

            this.deleteRemoteFile = deleteRemoteFile;

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

        public void disconnectRequired(boolean value) {
            this.disconnectRequired = value;
        }

        public DownloadFileOperation applyTo(Sftp transfer)
                throws SftpException {
            assertNotNull(transfer);
            this.transfer = transfer;

            try {
                while (shouldAttempt()) {
                    nextAttempt();
                }

                assertNotCancelled();
                assertNoErrors();

                return this;
            } catch (Exception e) {
                throw this.transfer.wrap(e);
            } finally {
                this.listener.onOperationCompleted(this);
            }
        }

        public void cancel() {
            this.cancelled = true;

            Sftp transfer = this.transfer;

            if (transfer != null) {
                transfer.interrupt();
            }
        }

        @Override
        public String toString() {
            return "DownloadFileOperation {"
                   + "remoteDirectory=" + this.remoteDirectory
                   + ", remoteFile='" + this.remoteFile + '\''
                   + ", localFile=" + this.localFile
                   + ", transferStrategy=" + this.transferStrategy
                   + ", fileTimestampPreservingRequired=" + this.fileTimestampPreservingRequired
                   + ", maxAttempts=" + this.maxAttempts
                   + ", attemptPeriod=" + this.attemptPeriod
                   + ", deleteRemoteFile=" + this.deleteRemoteFile
                   + ", attempts=" + this.attempts
                   + ", success=" + this.success
                   + ", error=" + this.error
                   + ", cancelled=" + this.cancelled
                   + '}';
        }

        private boolean shouldAttempt() {
            return !this.success && this.attempts < this.maxAttempts && !this.cancelled;
        }

        private void nextAttempt() {
            initAttempt();
            awaitAttemptPeriod();

            try {
                initTransfer();
                transferFile();
                preserveFileTimestamp();
                deleteRemoteFile();
                restoreCurrentDir();
                attemptSucceeded();
            } catch (Exception e) {
                attemptFailed(e);
            } finally {
                cleanup();
            }
        }

        private void assertNotCancelled()
                throws Exception {
            if (this.cancelled) {
                throw new SftpException("Transfer cancelled.");
            }
        }

        private void assertNoErrors()
                throws Exception {
            if (this.error != null) {
                throw this.error;
            }
        }

        private void initAttempt() {
            this.success = false;
            this.error = null;
        }

        private void awaitAttemptPeriod() {
            if (this.attempts > 0) {
                Clock.await(this.attemptPeriod.toMillis());
            }
        }

        private void initTransfer()
                throws Exception {
            if (!this.transfer.isConnected()) {
                this.transfer.connect();
            }

            this.currentRemoteDirectory = this.transfer.getDir();
            this.remoteDirectory.applyTo(this.transfer);

            File localDir = this.localFile.getParentFile();
            this.transfer.setLocalDir(localDir != null ? localDir : new File("."));
        }

        private void transferFile()
                throws Exception {
            this.transferStrategy.apply(this);
        }

        private void preserveFileTimestamp() {
            if (!this.fileTimestampPreservingRequired) {
                return;
            }

            try {
                Date lastModified = this.transfer.getFileTimestamp(this.remoteFile);
                Files.setLastModifiedTime(this.localFile.toPath(), FileTime.fromMillis(lastModified.getTime()));
            } catch (Exception e) {
                // ignore
            }
        }

        private void deleteRemoteFile() {
            if (!this.deleteRemoteFile) {
                return;
            }

            try {
                this.transfer.deleteFile(this.remoteFile);
            } catch (Exception e) {
                // ignore
            }
        }

        private void restoreCurrentDir() {
            try {
                this.transfer.setDir(this.currentRemoteDirectory);
            } catch (Exception e) {
                // ignore
            }
        }

        private void attemptSucceeded() {
            this.success = true;
        }

        private void attemptFailed(Exception e) {
            this.error = e;
            ++this.attempts;
        }

        private void cleanup() {
            if (!this.disconnectRequired) {
                return;
            }

            try {
                this.transfer.disconnect();
            } catch (Exception e) {
                // ignore
            }
        }

        public interface Listener {

            void onOperationCompleted(DownloadFileOperation operation);

        }

        public static class NullListener
                implements Listener {

            public void onOperationCompleted(DownloadFileOperation operation) {
                // empty
            }
        }

        public enum TransferStrategy {

            FROM_SCRATCH() {
                @Override
                public void apply(DownloadFileOperation operation)
                        throws Exception {
                    operation.transfer.download(operation.localFile.getName(), operation.remoteFile);
                }
            },
            ALWAYS_RESUME() {
                @Override
                public void apply(DownloadFileOperation operation)
                        throws Exception {
                    try {
                        long offset = operation.localFile.length();
                        operation.transfer.resumeDownload(operation.remoteFile, offset);
                    } catch (Exception e) {
                        FROM_SCRATCH.apply(operation);
                    }
                }
            },
            RESUME_AFTER_FIRST_ATTEMPT() {
                @Override
                public void apply(DownloadFileOperation operation)
                        throws Exception {
                    if (operation.attempts > 0) {
                        ALWAYS_RESUME.apply(operation);
                    } else {
                        FROM_SCRATCH.apply(operation);
                    }
                }
            };

            public abstract void apply(DownloadFileOperation operation)
                    throws Exception;
        }

    }

    public class DownloadDirectoryOperation
            implements DownloadFileOperation.Listener {

        private final String remoteDirectory;
        private final boolean fileTimestampPreservingRequired;
        private final int maxAttempts;
        private final Time attemptPeriod;
        private final String fileSeparator;
        private final Integer threadCount;
        private Sftp transfer;
        private Map<String, DownloadFileOperation> fileOperations;
        private ExecutorService executorService;
        private Tree<DirectoryService.Directory<SftpFile>> remoteDirectoryTree;
        private CountDownLatch doneSignal;
        private volatile boolean cancelled;

        public DownloadDirectoryOperation(String remoteDirectory,
                                          boolean fileTimestampPreservingRequired,
                                          int maxAttempts,
                                          Time attemptPeriod,
                                          String fileSeparator,
                                          Integer threadCount) {
            assertNotNull(remoteDirectory);
            this.remoteDirectory = remoteDirectory;

            this.fileTimestampPreservingRequired = fileTimestampPreservingRequired;

            Parameters.assertGreater(maxAttempts, 0, "Bad max attempts value.");
            this.maxAttempts = maxAttempts;

            assertNotNull(attemptPeriod);
            this.attemptPeriod = attemptPeriod;

            assertNotNull(fileSeparator);
            this.fileSeparator = fileSeparator;

            Parameters.assertTrue(threadCount == null || threadCount > 0, "Bad thread count value.");
            this.threadCount = threadCount;
        }

        public DownloadDirectoryOperation applyTo(Sftp transfer)
                throws SftpException {
            assertNotNull(transfer);
            this.transfer = transfer;

            this.cancelled = false;

            try {
                initTransfer();
                initRemoteDirectoryTree();
                createLocalDirectoryTree();
                initFileOperations();
                initDoneSignal();

                if (useThreads()) {
                    startFileOperations();
                } else {
                    runFileOperations();
                }

                awaitOperationsDone();
                assertNotCancelled();

                return this;
            } catch (Exception e) {
                throw this.transfer.wrap(e);
            } finally {
                cleanup();
            }
        }

        public void cancel() {
            this.cancelled = true;

            for (DownloadFileOperation operation : this.fileOperations.values()) {
                operation.cancel();
            }
        }

        public void cancel(String file) {
            DownloadFileOperation operation = findOperationFor(file);

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

        public int fileCount() {
            return this.fileOperations.size();
        }

        public void onOperationCompleted(DownloadFileOperation operation) {
            this.doneSignal.countDown();
        }

        @Override
        public String toString() {
            return "DownloadDirectoryOperation {"
                   + "remoteDirectory='" + this.remoteDirectory + '\''
                   + ", fileTimestampPreservingRequired=" + this.fileTimestampPreservingRequired
                   + ", maxAttempts=" + this.maxAttempts
                   + ", attemptPeriod=" + this.attemptPeriod
                   + ", fileSeparator='" + this.fileSeparator + '\''
                   + ", threadCount=" + this.threadCount
                   + ", remoteDirectoryTree=" + this.remoteDirectoryTree
                   + ", cancelled=" + this.cancelled
                   + '}';
        }

        private void initTransfer()
                throws Exception {
            if (!this.transfer.isConnected()) {
                this.transfer.connect();
            }
        }

        private void initRemoteDirectoryTree()
                throws Exception {
            this.remoteDirectoryTree = DirectoryService.asTree(this.remoteDirectory, this.transfer);
        }

        private void createLocalDirectoryTree()
                throws Exception {
            createLocalDirectoryTree(this.transfer.getLocalDir(), this.remoteDirectoryTree);
        }

        private void initFileOperations()
                throws Exception {
            this.fileOperations = new HashMap<>();
            LinkedList<String> remoteDirectoryPath = currentRemotePath();
            initFileOperations(remoteDirectoryPath, this.remoteDirectoryTree, this.transfer.getLocalDir());
        }

        private LinkedList<String> currentRemotePath()
                throws Exception {
            LinkedList<String> path = new LinkedList<>();
            path.add(this.transfer.getDir());
            return path;
        }

        private void initFileOperations(List<String> remoteDirectoryPath,
                                        Tree<DirectoryService.Directory<SftpFile>> tree,
                                        File parentLocalDirectory) {
            String directoryName = tree.value().path.getFilename();
            remoteDirectoryPath.add(directoryName);
            File localDirectory = new File(parentLocalDirectory, directoryName);

            initFileOperations(remoteDirectoryPath, tree.value().files, localDirectory);

            for (Tree<DirectoryService.Directory<SftpFile>> leaf : tree.leafs()) {
                initFileOperations(new LinkedList<>(remoteDirectoryPath), leaf, localDirectory);
            }
        }

        private void initDoneSignal() {
            this.doneSignal = new CountDownLatch(this.fileOperations.size());
        }

        private void createLocalDirectoryTree(File localDirectory,
                                              Tree<DirectoryService.Directory<SftpFile>> tree)
                throws Exception {
            String directoryName = tree.value().path.getFilename();
            File subDirectory = new File(localDirectory, directoryName);
            FileTools.createDirectory(subDirectory);

            for (Tree<DirectoryService.Directory<SftpFile>> leaf : tree.leafs()) {
                createLocalDirectoryTree(subDirectory, leaf);
            }
        }

        private void initFileOperations(List<String> remoteDirectoryPath,
                                        Collection<SftpFile> files,
                                        File localDirectory) {
            RemoteDirectory remoteDirectory = new RemoteDirectory.PathElementsDirectory(remoteDirectoryPath);

            for (SftpFile file : files) {
                String filename = file.getFilename();
                DownloadFileOperation operation = newOperationFor(remoteDirectory, new File(localDirectory, filename));
                this.fileOperations.put(fullRemotePathFor(remoteDirectoryPath, filename), operation);
            }
        }

        private String fullRemotePathFor(List<String> remoteDirectory, String filename) {
            StringBuilder buffer = new StringBuilder();

            for (String element : remoteDirectory) {
                buffer.append(element);

                if (!element.endsWith(this.fileSeparator)) {
                    buffer.append(this.fileSeparator);
                }
            }

            buffer.append(filename);

            return buffer.toString();
        }

        private boolean useThreads() {
            return this.threadCount != null;
        }

        private void startFileOperations() {
            this.executorService = Executors.newFixedThreadPool(this.threadCount);

            for (DownloadFileOperation candidate : this.fileOperations.values()) {
                final DownloadFileOperation operation = candidate;
                final Sftp transfer = useThreads() ? this.transfer.copy() : this.transfer;
                this.executorService.submit(new Callable<Object>() {
                    @Override
                    public Object call()
                            throws Exception {
                        operation.applyTo(transfer);
                        return null;
                    }
                });
            }
        }

        private void awaitOperationsDone() {
            try {
                this.doneSignal.await();
            } catch (InterruptedException e) {
                // todo: process error
            }
        }

        private void assertNotCancelled()
                throws Exception {
            if (this.cancelled) {
                throw new SftpException("Transfer cancelled.");
            }
        }

        private void cleanup() {
            if (this.executorService != null) {
                this.executorService.shutdownNow();
                this.executorService = null;
            }

            this.remoteDirectoryTree = null;
            this.doneSignal = null;
        }

        private DownloadFileOperation findOperationFor(String file) {
            return this.fileOperations.get(file);
        }

        private DownloadFileOperation newOperationFor(RemoteDirectory remoteDirectory, File file) {
            return new DownloadFileOperation(remoteDirectory,
                                             null,
                                             file,
                                             DownloadFileOperation.TransferStrategy.RESUME_AFTER_FIRST_ATTEMPT,
                                             this.fileTimestampPreservingRequired,
                                             this.maxAttempts,
                                             this.attemptPeriod,
                                             false,
                                             this);
        }

        private void runFileOperations()
                throws Exception {
            for (DownloadFileOperation operation : this.fileOperations.values()) {
                run(operation);
            }
        }

        private void run(DownloadFileOperation operation)
                throws Exception {
            Sftp transfer = useThreads() ? this.transfer.copy() : this.transfer;
            operation.disconnectRequired(useThreads());
            operation.applyTo(transfer);
        }
    }

    public static class UploadFileOperation {

        private final File localFile;
        private final RemoteDirectory remoteDirectory;
        private final String remoteFile;
        private final TransferStrategy transferStrategy;
        private final boolean renameRemoteFileRequired;
        private final boolean fileTimestampPreservingRequired;
        private final int maxAttempts;
        private final Time attemptPeriod;
        private final boolean deleteLocalFileRequired;
        private final Listener listener;
        private boolean disconnectRequired;
        private Sftp transfer;
        private String currentRemoteDirectory;
        private int attempts;
        private boolean succeeded;
        private Exception error;
        private volatile boolean cancelled;

        public UploadFileOperation(File localFile,
                                   RemoteDirectory remoteDirectory,
                                   String tempFileExtension,
                                   TransferStrategy transferStrategy,
                                   boolean fileTimestampPreservingRequired,
                                   int maxAttempts,
                                   Time attemptPeriod,
                                   boolean deleteLocalFileRequired,
                                   Listener listener) {
            assertNotNull(remoteDirectory);
            this.remoteDirectory = remoteDirectory;

            assertTrue(localFile.isFile(), "Specified pathname is not a file.");
            this.localFile = localFile;

            String remoteFileExtension = StringTools.isValidString(tempFileExtension) ? tempFileExtension.startsWith(".") ? tempFileExtension : "." + tempFileExtension : "";
            this.remoteFile = localFile.getName() + remoteFileExtension;

            assertNotNull(transferStrategy);
            this.transferStrategy = transferStrategy;

            this.renameRemoteFileRequired = remoteFileExtension.length() > 0;
            this.fileTimestampPreservingRequired = fileTimestampPreservingRequired;

            assertGreater(maxAttempts, 0, "Bad max attempts value.");
            this.maxAttempts = maxAttempts;

            assertNotNull(attemptPeriod);
            this.attemptPeriod = attemptPeriod;

            this.deleteLocalFileRequired = deleteLocalFileRequired;

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

        public void disconnectRequired(boolean value) {
            this.disconnectRequired = value;
        }

        public UploadFileOperation applyTo(Sftp transfer)
                throws SftpException {
            assertNotNull(transfer);
            this.transfer = transfer;

            try {
                while (shouldAttempt()) {
                    nextAttempt();
                }

                assertNotCancelled();
                assertNoErrors();

                return this;
            } catch (Exception e) {
                throw this.transfer.wrap(e);
            } finally {
                this.listener.onOperationCompleted(this);
            }
        }

        public void cancel() {
            this.cancelled = true;

            Sftp transfer = this.transfer;

            if (transfer != null) {
                transfer.interrupt();
            }
        }

        @Override
        public String toString() {
            return "UploadFileOperation {"
                   + "localFile=" + this.localFile
                   + ", remoteDirectory=" + this.remoteDirectory
                   + ", remoteFile='" + this.remoteFile + '\''
                   + ", transferStrategy=" + this.transferStrategy
                   + ", renameRemoteFileRequired=" + this.renameRemoteFileRequired
                   + ", fileTimestampPreservingRequired=" + this.fileTimestampPreservingRequired
                   + ", maxAttempts=" + this.maxAttempts
                   + ", attemptPeriod=" + this.attemptPeriod
                   + ", deleteLocalFileRequired=" + this.deleteLocalFileRequired
                   + ", attempts=" + this.attempts
                   + ", succeeded=" + this.succeeded
                   + ", error=" + this.error
                   + ", cancelled=" + this.cancelled
                   + '}';
        }

        private boolean shouldAttempt() {
            return !this.succeeded && this.attempts < this.maxAttempts && !this.cancelled;
        }

        private void nextAttempt() {
            initAttempt();
            awaitAttemptPeriod();

            try {
                initTransfer();
                transferFile();
                completeTransfer();
                preserveFileTimestamp();
                deleteLocalFile();
                restoreCurrentDir();
                attemptSucceeded();
            } catch (Exception e) {
                attemptFailed(e);
            } finally {
                cleanup();
            }
        }

        private void assertNotCancelled()
                throws Exception {
            if (this.cancelled) {
                throw new SftpException("Transfer cancelled.");
            }
        }

        private void assertNoErrors()
                throws Exception {
            if (this.error != null) {
                throw this.error;
            }
        }

        private void initAttempt() {
            this.succeeded = false;
            this.error = null;
        }

        private void awaitAttemptPeriod() {
            if (this.attempts > 0) {
                Clock.await(this.attemptPeriod.toMillis());
            }
        }

        private void initTransfer()
                throws Exception {
            if (!this.transfer.isConnected()) {
                this.transfer.connect();
            }

            this.currentRemoteDirectory = this.transfer.getDir();
            this.remoteDirectory.applyTo(this.transfer);
        }

        private void transferFile()
                throws Exception {
            this.transferStrategy.apply(this);
        }

        private void completeTransfer()
                throws Exception {
            if (this.renameRemoteFileRequired) {
                this.transfer.renameFile(this.remoteFile, this.localFile.getName());
            }
        }

        private void preserveFileTimestamp() {
            if (!this.fileTimestampPreservingRequired) {
                return;
            }

            try {
                this.transfer.setFileTimestamp(this.remoteFile, new Date(this.localFile.lastModified()));
            } catch (Exception e) {
                // ignore
            }
        }

        private void deleteLocalFile() {
            if (!this.deleteLocalFileRequired) {
                return;
            }

            try {
                this.localFile.delete();
            } catch (Exception e) {
                // ignore
            }
        }

        private void restoreCurrentDir() {
            try {
                this.transfer.setDir(this.currentRemoteDirectory);
            } catch (Exception e) {
                // ignore
            }
        }

        private void attemptSucceeded() {
            this.succeeded = true;
        }

        private void attemptFailed(Exception e) {
            this.error = e;
            ++this.attempts;
        }

        private void cleanup() {
            if (!this.disconnectRequired) {
                return;
            }

            try {
                this.transfer.disconnect();
            } catch (Exception e) {
                // ignore
            }
        }

        public interface Listener {

            void onOperationCompleted(UploadFileOperation operation);

        }

        public static class NullListener
                implements Listener {

            public void onOperationCompleted(UploadFileOperation operation) {
                // empty
            }
        }

        public enum TransferStrategy {

            FROM_SCRATCH() {
                @Override
                public void apply(UploadFileOperation operation)
                        throws Exception {
                    operation.transfer.upload(operation.localFile, operation.remoteFile);
                }
            },
            ALWAYS_RESUME() {
                @Override
                public void apply(UploadFileOperation operation)
                        throws Exception {
                    try {
                        long offset = operation.transfer.getFilesize(operation.remoteFile);
                        operation.transfer.resumeUpload(operation.localFile, offset);
                    } catch (Exception e) {
                        FROM_SCRATCH.apply(operation);
                    }
                }
            },
            RESUME_AFTER_FIRST_ATTEMPT() {
                @Override
                public void apply(UploadFileOperation operation)
                        throws Exception {
                    if (operation.attempts > 0) {
                        ALWAYS_RESUME.apply(operation);
                    } else {
                        FROM_SCRATCH.apply(operation);
                    }
                }
            };

            public abstract void apply(UploadFileOperation operation)
                    throws Exception;
        }

    }

    public static class UploadDirectoryOperation
            implements UploadFileOperation.Listener {

        private final File localDirectory;
        private final String tempFileExtension;
        private final boolean fileTimestampPreservingRequired;
        private final int maxAttempts;
        private final Time attemptPeriod;
        private final Integer threadCount;
        private Sftp transfer;
        private Map<File, UploadFileOperation> fileOperations;
        private ExecutorService executorService;
        private Tree<DirectoryService.Directory<File>> localDirectoryTree;
        private CountDownLatch doneSignal;
        private volatile boolean cancelled;

        public UploadDirectoryOperation(File localDirectory,
                                        String tempFileExtension,
                                        boolean fileTimestampPreservingRequired,
                                        int maxAttempts,
                                        Time attemptPeriod,
                                        Integer threadCount) {
            assertTrue(localDirectory.isDirectory(), "Specified pathname is not a directory.");
            this.localDirectory = localDirectory;

            this.tempFileExtension = tempFileExtension;
            this.fileTimestampPreservingRequired = fileTimestampPreservingRequired;

            assertGreater(maxAttempts, 0, "Bad max attempts value.");
            this.maxAttempts = maxAttempts;

            assertNotNull(attemptPeriod);
            this.attemptPeriod = attemptPeriod;

            assertTrue(threadCount == null || threadCount > 0, "Bad thread count value.");
            this.threadCount = threadCount;
        }

        public UploadDirectoryOperation applyTo(Sftp transfer)
                throws SftpException {
            assertNotNull(transfer);
            this.transfer = transfer;

            this.cancelled = false;

            try {
                initTransfer();
                initLocalDirectoryTree();
                createRemoteDirectoryTree();
                initFileOperations();
                initDoneSignal();

                if (useThreads()) {
                    startFileOperations();
                } else {
                    runFileOperations();
                }

                awaitOperationsDone();
                assertNotCancelled();

                return this;
            } catch (Exception e) {
                throw this.transfer.wrap(e);
            } finally {
                cleanup();
            }
        }

        public void cancel() {
            this.cancelled = true;

            for (UploadFileOperation operation : this.fileOperations.values()) {
                operation.cancel();
            }
        }

        public void cancel(File file) {
            UploadFileOperation operation = findOperationFor(file);

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

        public int fileCount() {
            return this.fileOperations.size();
        }

        public void onOperationCompleted(UploadFileOperation operation) {
            this.doneSignal.countDown();
        }

        @Override
        public String toString() {
            return "UploadDirectoryOperation {"
                   + "localDirectory=" + this.localDirectory
                   + ", tempFileExtension='" + this.tempFileExtension + '\''
                   + ", fileTimestampPreservingRequired=" + this.fileTimestampPreservingRequired
                   + ", maxAttempts=" + this.maxAttempts
                   + ", attemptPeriod=" + this.attemptPeriod
                   + ", threadCount=" + this.threadCount
                   + ", localDirectoryTree=" + this.localDirectoryTree
                   + ", cancelled=" + this.cancelled
                   + '}';
        }

        private void initTransfer()
                throws Exception {
            if (!this.transfer.isConnected()) {
                this.transfer.connect();
            }
        }

        private void initLocalDirectoryTree() {
            this.localDirectoryTree = DirectoryService.asTree(this.localDirectory);
        }

        private void createRemoteDirectoryTree()
                throws Exception {
            createRemoteDirectoryTree(this.localDirectoryTree);
        }

        private void initFileOperations()
                throws Exception {
            this.fileOperations = new HashMap<>();
            List<String> remoteDirectoryPath = currentRemoteDirectoryPath();
            initFileOperations(this.localDirectoryTree, remoteDirectoryPath);
        }

        private void initDoneSignal() {
            this.doneSignal = new CountDownLatch(this.fileOperations.size());
        }

        private boolean useThreads() {
            return this.threadCount != null;
        }

        private void startFileOperations() {
            this.executorService = Executors.newFixedThreadPool(this.threadCount);

            for (UploadFileOperation candidate : this.fileOperations.values()) {
                final UploadFileOperation operation = candidate;
                final Sftp transfer = useThreads() ? this.transfer.copy() : this.transfer;
                this.executorService.submit(new Callable<Object>() {
                    @Override
                    public Object call()
                            throws Exception {
                        operation.applyTo(transfer);
                        return null;
                    }
                });
            }
        }

        private void awaitOperationsDone() {
            try {
                this.doneSignal.await();
            } catch (InterruptedException e) {
                // todo: process error
            }
        }

        private void assertNotCancelled()
                throws Exception {
            if (this.cancelled) {
                throw new SftpException("Transfer cancelled.");
            }
        }

        private void cleanup() {
            if (this.executorService != null) {
                this.executorService.shutdownNow();
                this.executorService = null;
            }

            this.localDirectoryTree = null;
            this.doneSignal = null;
        }

        private void createRemoteDirectoryTree(Tree<DirectoryService.Directory<File>> localTree)
                throws Exception {
            String directoryName = localTree.value().path.getName();

            if (!this.transfer.exists(directoryName)) {
                this.transfer.makeDir(directoryName);
            }

            this.transfer.setDir(directoryName);

            for (Tree<DirectoryService.Directory<File>> leaf : localTree.leafs()) {
                createRemoteDirectoryTree(leaf);
            }

            this.transfer.setDirUp();
        }

        private List<String> currentRemoteDirectoryPath()
                throws Exception {
            LinkedList<String> path = new LinkedList<>();
            path.add(this.transfer.getDir());

            return path;
        }

        private void initFileOperations(Tree<DirectoryService.Directory<File>> localTree, List<String> remoteDirectoryPath) {
            remoteDirectoryPath.add(localTree.value().path.getName());

            initFileOperations(localTree.value().files, new RemoteDirectory.PathElementsDirectory(remoteDirectoryPath));

            for (Tree<DirectoryService.Directory<File>> leaf : localTree.leafs()) {
                initFileOperations(leaf, new LinkedList<>(remoteDirectoryPath));
            }
        }

        private void initFileOperations(Collection<File> files, RemoteDirectory remoteDirectory) {
            for (File file : files) {
                UploadFileOperation operation = newOperationFor(file, remoteDirectory);
                this.fileOperations.put(file, operation);
            }
        }

        private UploadFileOperation findOperationFor(File file) {
            return this.fileOperations.get(file);
        }

        private UploadFileOperation newOperationFor(File file, RemoteDirectory remoteDirectory) {
            return new UploadFileOperation(file,
                                           remoteDirectory,
                                           this.tempFileExtension,
                                           UploadFileOperation.TransferStrategy.RESUME_AFTER_FIRST_ATTEMPT,
                                           this.fileTimestampPreservingRequired,
                                           this.maxAttempts,
                                           this.attemptPeriod,
                                           false,
                                           this);
        }

        private void runFileOperations()
                throws Exception {
            for (UploadFileOperation operation : this.fileOperations.values()) {
                run(operation);
            }
        }

        private void run(UploadFileOperation operation)
                throws Exception {
            Sftp transfer = useThreads() ? this.transfer.copy() : this.transfer;
            operation.disconnectRequired(useThreads());
            operation.applyTo(transfer);
        }

    }

    public static class SftpPipedInputStream
            extends PipedInputStream
            implements Runnable {

        private final Sftp transfer;
        private final String remoteFile;
        private final long offset;
        private final PipedOutputStream out;
        private volatile Exception error;

        public static SftpPipedInputStream streamFor(Sftp transfer, String remoteFile, long offset)
                throws Exception {
            PipedOutputStream out = new PipedOutputStream();
            SftpPipedInputStream in = new SftpPipedInputStream(transfer, remoteFile, offset, out);
            out.connect(in);

            return in;
        }

        private SftpPipedInputStream(Sftp transfer, String remoteFile, long offset, PipedOutputStream out) {
            assertNotNull(transfer);
            this.transfer = transfer;

            assertNotNull(remoteFile);
            this.remoteFile = remoteFile;

            this.offset = offset;

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

        @Override
        public void run() {
            try {
                this.transfer.resumeDownload(this.out, this.remoteFile, this.offset);
            } catch (Exception e) {
                this.error = e;
            } finally {
                IOTools.close(out);
            }
        }

        @Override
        public synchronized int read()
                throws IOException {
            assertNoErrors();
            return super.read();
        }

        @Override
        public synchronized int read(byte[] buffer, int offset, int length)
                throws IOException {
            assertNoErrors();
            return super.read(buffer, offset, length);
        }

        private void assertNoErrors()
                throws IOException {
            if (this.error != null) {
                throw IOTools.ioException(this.error);
            }
        }
    }

    public static class SftpPipedOutputStream
            extends PipedOutputStream
            implements Runnable {

        private final Sftp transfer;
        private final String remoteFile;
        private final long offset;
        private final long length;
        private final PipedInputStream in;
        private final Lock lock;
        private final Condition completedCondition;
        private boolean completed;
        private volatile Exception error;

        public static SftpPipedOutputStream streamFor(Sftp transfer, String remoteFile, long offset, long length)
                throws Exception {
            PipedInputStream in = new PipedInputStream();
            SftpPipedOutputStream out = new SftpPipedOutputStream(transfer, remoteFile, offset, length, in);
            out.connect(in);

            return out;
        }

        private SftpPipedOutputStream(Sftp transfer, String remoteFile, long offset, long length, PipedInputStream in)  {
            assertNotNull(transfer);
            this.transfer = transfer;

            assertNotNull(remoteFile);
            this.remoteFile = remoteFile;

            this.offset = offset;
            this.length = length;

            assertNotNull(in);
            this.in = in;

            this.lock = new ReentrantLock();
            this.completedCondition = this.lock.newCondition();
        }

        @Override
        public void run() {
            this.lock.lock();

            try {
                this.transfer.resumeUpload(this.in, this.length, this.remoteFile, this.offset);
            } catch (Exception e) {
                this.error = e;
            } finally {
                this.completed = true;
                this.completedCondition.signalAll();
                this.lock.unlock();
            }
        }

        @Override
        public void write(int data)
                throws IOException {
            super.write(data);
            assertNoErrors();
        }

        @Override
        public void write(byte[] data, int offset, int length)
                throws IOException {
            super.write(data, offset, length);
            assertNoErrors();
        }

        @Override
        public synchronized void flush()
                throws IOException {
            super.close();
            awaitDataUploaded();
            assertNoErrors();
        }

        @Override
        public void close()
                throws IOException {
            super.close();
            awaitDataUploaded();
            assertNoErrors();
        }

        private void awaitDataUploaded() {
            this.lock.lock();

            try {
                while (!this.completed) {
                    try {
                        this.completedCondition.await(1, TimeUnit.MILLISECONDS);
                    } catch (Exception e) {
                        // ignore
                    }
                }
            } finally {
                this.lock.unlock();
            }
        }

        private void assertNoErrors()
                throws IOException {
            if (this.error != null) {
                throw IOTools.ioException(this.error);
            }
        }

    }
}
