/*
 * @(#)Scp.java
 *
 * Copyright (c) 2001-2006 JScape
 * All rights reserved.
 *
 * This software is the confidential and proprietary information of
 * JScape. ("Confidential Information").  You shall not disclose such
 * Confidential Information and shall use it only in accordance with
 * the terms of the license agreement you entered into with JScape.
 */
package com.jscape.inet.scp;

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.scp.events.*;
import com.jscape.inet.ssh.SshConfiguration;
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.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.regex.*;

import java.io.*;
import java.net.InetAddress;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.FileTime;
import java.util.*;
import java.util.concurrent.CopyOnWriteArraySet;
import java.util.logging.Level;
import java.util.logging.Logger;

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


/**
 * Implements the basic functionality of an SCP (Secure Copy) client over SSH2 protocol.<p>
 * <p/>
 * Example Usage:<P>
 * <p/>
 * <pre>
 * // create new SshParamters 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));
 *
 * // create new Scp instance
 * Scp scp = new Scp(params, sshConfiguration);
 *
 * // register event listener
 * scp.addListener(this);
 *
 * // establish connection
 * scp.connect();
 *
 * // upload file to remote directory
 * scp.upload(new File("c:/tmp/file.txt"),"/home/jsmith/");
 *
 * // disconnect
 * scp.disconnect();
 * </pre>
 */
public class Scp
        implements AutoCloseable {

    public static final File DEFAULT_LOCAL_DIRECTORY = new File(".");
    public static final int DEFAULT_BUFFER_SIZE = 128 * 1024;
    public static final boolean DEFAULT_PRESERVE_FILE_ATTRIBUTES = true;

    private static final MatcherFactory REGEX_FACTORY = new WildcardMatcherFactory();

    private SshParameters parameters;
    private SshConfiguration configuration;
    private Path currentLocalDirectory;
    private int transferBufferSizeBytes;
    private boolean fileAttributesPreservingRequired;
    private final Logger logger;
    private final Set<ScpEventListener> listeners;
    private final SshHostKeys hostKeys;
    private volatile FileService service;

    /**
     * Creates a new SCP client instance.
     *
     * @param parameters SSH connection parameters
     * @param configuration SSH configuration
     * @param currentLocalDirectory current local directory
     * @param transferBufferSizeBytes transfer buffer size in bytes
     * @param fileAttributesPreservingRequired file attributes preserving required flag
     * @param logger logger
     */
    public Scp(SshParameters parameters,
               SshConfiguration configuration,
               File currentLocalDirectory,
               int transferBufferSizeBytes,
               boolean fileAttributesPreservingRequired,
               Logger logger) {
        assertLicenseValid();

        assertNotNull(parameters);
        this.parameters = parameters;

        assertNotNull(configuration);
        this.configuration = configuration;

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

        assertGreater(transferBufferSizeBytes, 0, "Bad transfer buffer size value.");
        this.transferBufferSizeBytes = transferBufferSizeBytes;

        this.fileAttributesPreservingRequired = fileAttributesPreservingRequired;

        assertNotNull(logger);
        this.logger = logger;

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

    /**
     * Creates a new SCP client instance.
     *
     * @param parameters SSH connection parameters
     * @param configuration SSH configuration
     */
    public Scp(SshParameters parameters, SshConfiguration configuration) {
        this(parameters, configuration, DEFAULT_LOCAL_DIRECTORY, DEFAULT_BUFFER_SIZE, DEFAULT_PRESERVE_FILE_ATTRIBUTES, DebugStreamLogger.systemOutLogger(Level.OFF));
    }

    /**
     * Creates a new SCP client instance.
     *
     * @param parameters SSH connection parameters
     * @param logger logger
     */
    public Scp(SshParameters parameters, Logger logger) {
        this(parameters, SshConfiguration.defaultClientConfiguration(), DEFAULT_LOCAL_DIRECTORY, DEFAULT_BUFFER_SIZE, DEFAULT_PRESERVE_FILE_ATTRIBUTES, logger);
    }

    /**
     * Creates a new SCP client instance.
     *
     * @param parameters SSH connection parameters
     */
    public Scp(SshParameters parameters) {
        this(parameters, SshConfiguration.defaultClientConfiguration());
    }

    /**
     * Returns current SSH parameters.
     *
     * @return SSH parameters
     */
    public SshParameters getParameters() {
        return this.parameters;
    }

    /**
     * Sets current SSH parameters.
     *
     * @param value SSH parameters
     */
    public void setParameters(SshParameters value) {
        assertNotNull(value);
        this.parameters = value;
    }

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

    /**
     * Sets current SSH configuration.
     *
     * @param value SSH configuration
     */
    public void setConfiguration(SshConfiguration value) {
        assertNotNull(value);
        this.configuration = value;
    }

    /**
     * Returns current local directory.
     *
     * @return local directory
     */
    public File getLocalDir() {
        return this.currentLocalDirectory.toFile();
    }

    /**
     * Sets current local directory.
     *
     * @param value local directory
     */
    public void setLocalDir(File value) {
        assertTrue(value.isDirectory(), "Invalid local directory.");
        this.currentLocalDirectory = value.toPath().normalize();
    }

    /**
     * Returns current transfer buffer size.
     *
     * @return transfer buffer size in bytes
     */
    public int getTransferBufferSizeBytes() {
        return this.transferBufferSizeBytes;
    }

    /**
     * Sets current transfer buffer size.
     *
     * @param value transfer buffer size in bytes
     */
    public void setTransferBufferSizeBytes(int value) {
        assertGreater(value, 0, "Bad transfer buffer size value.");
        this.transferBufferSizeBytes = value;
    }

    /**
     * Returns upload block size in bytes.
     *
     * @return upload block size in bytes
     *
     * @deprecated use getTransferBufferSizeBytes()
     */
    public int getUploadBlockSize() {
        return getTransferBufferSizeBytes();
    }

    /**
     * Sets upload block size.
     *
     * @param value upload block size in bytes
     *
     * @deprecated use setTransferBufferSizeBytes()
     */
    public void setUploadBlockSize(int value) {
        setTransferBufferSizeBytes(value);
    }

    /**
     * Returns download block size in bytes.
     *
     * @return download block size in bytes
     *
     * @deprecated getTransferBufferSizeBytes()
     */
    public int getDownloadBlockSize() {
        return getTransferBufferSizeBytes();
    }

    /**
     * Sets download block size.
     *
     * @param value download block size in bytes
     *
     * @deprecated use setTransferBufferSizeBytes()
     */
    public void setDownloadBlockSize(int value) {
        setTransferBufferSizeBytes(value);
    }

    /**
     * Returns current file attributes preserving required flag.
     *
     * @return file attributes preserving required flag
     */
    public boolean isFileAttributesPreservingRequired() {
        return this.fileAttributesPreservingRequired;
    }

    /**
     * Sets file attributes preserving required flag.
     *
     * @param value file attributes preserving required flag
     */
    public void setFileAttributesPreservingRequired(boolean value) {
        this.fileAttributesPreservingRequired = value;
    }

    /**
     * Checks if preserve time flag is set.
     *
     * @return preserve flag value
     *
     *  @deprecated use isFileAttributesPreservingRequired
     */
    public boolean getPreserveTime() {
        return isFileAttributesPreservingRequired();
    }

    /**
     * Sets preserver time flag.
     *
     * @param value preserve time
     *
     * @deprecated use setFileAttributesPreservingRequired
     */
    public void setPreserveTime(boolean value) {
        setFileAttributesPreservingRequired(value);
    }

    /**
     * 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);
    }

    /**
     * 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 an event listener.
     *
     * @param listener event listener
     */
    public void addListener(ScpEventListener listener) {
        assertNotNull(listener);
        this.listeners.add(listener);
    }

    /**
     * Removes an even listener.
     *
     * @param listener target listener
     */
    public void removeListener(ScpEventListener listener) {
        this.listeners.remove(listener);
    }

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

    /**
     * Connects to the server.
     *
     * @throws ScpException if an error occurs
     */
    public synchronized Scp connect()
            throws ScpException {
        try {
            initService();
            this.service.start(null);
            this.hostKeys.put(InetAddress.getByName(this.parameters.getHostname()), ((ScpFileService) this.service).sessionConnection().hostKey());
            raiseEvent(new ScpConnectedEvent(this, this.parameters.getHostname(), this.parameters.getPort()));

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

    /**
     * Disconnects from the server.
     */
    public synchronized void disconnect() {
        if (!isConnected()) {
            return;
        }

        this.service.dispose();
        this.service = null;
        raiseEvent(new ScpDisconnectedEvent(this, this.parameters.getHostname(), this.parameters.getPort()));
    }

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

    /**
     * Checks if client is connected.
     *
     * @return <code>true</code> if client is connected; <code>false</code> otherwise
     */
    public synchronized boolean isConnected() {
        return this.service != null && this.service.isStarted();
    }

    /**
     * Interrupts current transfer process.
     */
    public void interrupt() {
        FileService service = this.service;

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

    /**
     * Downloads a file.
     *
     * @param destinationStream destination stream
     * @param remoteDirectory remote directory path with ending path separator (i.e "/user/home/") or empty line for current directory
     * @param remoteFile source filename
     *
     * @throws IOException if an error occurs
     */
    public void download(OutputStream destinationStream, String remoteDirectory, String remoteFile)
            throws IOException {
        String sourcePath = remoteDirectory + remoteFile;

        try {
            this.service.readFile(sourcePath, destinationStream, false, new DownloadListener());
        } catch (FileService.OperationException e) {
            throw new ScpException(e);
        }
    }

    /**
     * Downloads a file.
     *
     * @param remoteDirectory remote directory path with ending path separator (i.e "/user/home/") or empty line for current directory
     * @param remoteFile source filename
     * @param localFile destination file
     *
     * @return destination file
     *
     * @throws IOException if an error occurs
     */
    public File download(String remoteDirectory, String remoteFile, File localFile)
            throws IOException {
        OutputStream out = new FileOutputStream(localFile);

        try {
            String sourcePath = remoteDirectory + remoteFile;
            Long fileModificationTime = this.service.readFile(sourcePath, out, this.fileAttributesPreservingRequired, new DownloadListener());
            IOTools.close(out);

            updateLastModifiedTime(localFile, fileModificationTime);

            return localFile;
        } catch (FileService.OperationException e) {
            throw new ScpException(e);
        } finally {
            IOTools.close(out);
        }
    }

    /**
     * Downloads the file from SSH server.
     *
     * @param remoteDirectory remote directory path with ending path separator (i.e "/user/home/") or empty line for current directory
     * @param remoteFile source/destination filename
     *
     * @return destination file
     *
     * @throws IOException if an error occurs
     */
    public File download(String remoteDirectory, String remoteFile)
            throws IOException {
        return download(remoteDirectory, remoteFile, resolveLocalPathFor(remoteFile));
    }

    /**
     * Downloads multiple files. Downloads are stored relative to current local directory.
     *
     * @param remoteDirectory remote directory path with ending path separator (i.e "/user/home/") or empty line for current directory
     * @param remoteFiles source file names
     *
     * @throws IOException if an error occurs
     */
    public void mdownload(String remoteDirectory, Collection<String> remoteFiles)
            throws IOException {
        for (String fileName : remoteFiles) {
            download(remoteDirectory, fileName);
        }
    }

    /**
     * Downloads multiple files. Downloads are stored relative to current local directory.
     *
     * @param remoteDirectory remote directory path with ending path separator (i.e "/user/home/") or empty line for current directory
     * @param remoteFiles source file names
     *
     * @throws IOException if an error occurs
     */
    public void mdownload(String remoteDirectory, String... remoteFiles)
            throws IOException {
        for (String fileName : remoteFiles) {
            download(remoteDirectory, fileName);
        }
    }

    /**
     * Downloads multiple files. Downloads are stored relative to current local directory.
     *
     * @param remoteDirectory remote directory path with ending path separator (i.e "/user/home/") or empty line for current directory
     * @param remoteFiles source file names
     *
     * @throws IOException if an error occurs
     */
    public void mdownload(String remoteDirectory, Enumeration<String> remoteFiles)
            throws IOException {
        while (remoteFiles.hasMoreElements()) {
            download(remoteDirectory, remoteFiles.nextElement());
        }
    }

    /**
     * Downloads multiple files. Downloads are stored relative to current local directory.
     *
     * @param remoteDirectory remote directory path with ending path separator (i.e "/user/home/") or empty line for current directory
     * @param remoteFiles source file names
     *
     * @throws IOException if an error occurs
     */
    public void mdownload(String remoteDirectory, Iterator<String> remoteFiles)
            throws IOException {
        while (remoteFiles.hasNext()) {
            download(remoteDirectory, remoteFiles.next());
        }
    }

    /**
     * Downloads multiple files matching filter from remote 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 "*.txt".  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*".
     *
     * @param remoteDirectory remote directory path with ending path separator (i.e "/user/home/") or empty line for current directory
     * @param wildcard file filter wildcard
     *
     * @throws IOException if an error occurs
     */
    public void mdownload(String remoteDirectory, String wildcard)
            throws IOException {
        String sourcePath = remoteDirectory + wildcard;

        try {
            this.service.readFiles(sourcePath, this.currentLocalDirectory.toFile(), this.fileAttributesPreservingRequired, new DownloadListener());
        } catch (FileService.OperationException e) {
            throw new ScpException(e);
        }
    }

    /**
     * Downloads a directory recursively.
     *
     * @param remoteParentDirectory remote directory path with ending path separator (i.e "/user/home/") or empty line for current directory
     * @param remoteDirectory directory name
     *
     * @throws IOException if an error occurs
     */
    public void downloadDir(String remoteParentDirectory, String remoteDirectory)
            throws IOException {
        String sourcePath = remoteParentDirectory + remoteDirectory;

        try {
            this.service.readDirectory(sourcePath, this.currentLocalDirectory.toFile(), this.fileAttributesPreservingRequired, new DownloadListener());
        } catch (FileService.OperationException e) {
            throw new ScpException(e);
        }
    }

    /**
     * Uploads data.
     *
     * @param sourceStream source data stream
     * @param sourceLengthBytes total amount of data to upload in bytes
     * @param remoteDirectory remote directory path with ending path separator (i.e "/user/home/") or empty line for current directory
     * @param remoteFile remote file name relative to parent remote directory
     *
     * @throws IOException if an error occurs
     */
    public void upload(InputStream sourceStream, long sourceLengthBytes, String remoteDirectory, String remoteFile)
            throws IOException {
        try {
            this.service.writeFile(sourceStream, remoteDirectory, remoteFile, sourceLengthBytes, null, null, new UploadListener());
        } catch (FileService.OperationException e) {
            throw new ScpException(e);
        }
    }

    /**
     * Uploads data.
     *
     * @param sourceData source data array
     * @param remoteDirectory remote directory path with ending path separator (i.e "/user/home/") or empty line for current directory
     * @param remoteFile remote file name relative to parent remote directory
     *
     * @throws IOException if an error occurs
     */
    public void upload(byte[] sourceData, String remoteDirectory, String remoteFile)
            throws IOException {
        upload(new ByteArrayInputStream(sourceData), sourceData.length, remoteDirectory, remoteFile);
    }

    /**
     * Uploads data.
     * This operation uses InputStream.available() for computing data amount to send.
     * For data amount more than 2 Gb use
     * Scp.upload(InputStream in, long dataLengthBytes, String remoteParentDirectory, String remoteFilename) method.
     *
     * @param sourceStream source data stream
     * @param remoteDirectory remote directory path with ending path separator (i.e "/user/home/") or empty line for current directory
     * @param remoteFile remote file name relative to parent remote directory
     *
     * @throws IOException if an error occurs
     */
    public void upload(InputStream sourceStream, String remoteDirectory, String remoteFile)
            throws IOException {
        upload(sourceStream, sourceStream.available(), remoteDirectory, remoteFile);
    }

    /**
     * Uploads a file.
     *
     * @param localFile source file
     * @param remoteDirectory remote directory path with ending path separator (i.e "/user/home/") or empty line for current directory
     * @param remoteFile remote file name relative to parent remote directory
     *
     * @throws IOException if an error occurs
     */
    public void upload(File localFile, String remoteDirectory, String remoteFile)
            throws IOException {
        InputStream in = new FileInputStream(localFile);

        try {
            Long modificationDate = this.fileAttributesPreservingRequired ? localFile.lastModified() : null;
            this.service.writeFile(in, remoteDirectory, remoteFile, localFile.length(), null, modificationDate, new UploadListener());
        } catch (FileService.OperationException e) {
            throw new ScpException(e);
        } finally {
            IOTools.close(in);
        }
    }

    /**
     * Uploads a file.
     *
     * @param localFile source file
     * @param remoteDirectory remote directory path with ending path separator (i.e "/user/home/") or empty line for current directory
     *
     * @throws IOException if an error occurs
     */
    public void upload(File localFile, String remoteDirectory)
            throws IOException {
        upload(localFile, remoteDirectory, localFile.getName());
    }

    /**
     * Uploads a file.
     *
     * @param localFile source file name
     * @param remoteDirectory remote directory path with ending path separator (i.e "/user/home/") or empty line for current directory
     * @param remoteFile remote file name relative to parent remote directory
     *
     * @throws IOException if an error occurs
     */
    public void upload(String localFile, String remoteDirectory, String remoteFile)
            throws IOException {
        upload(resolveLocalPathFor(localFile), remoteDirectory, remoteFile);
    }

    /**
     * Uploads a file.
     *
     * @param remoteDirectory remote directory path with ending path separator (i.e "/user/home/") or empty line for current directory
     * @param localFile file to upload relative to the current local directory
     *
     * @throws IOException if an error occurs
     */
    public void upload(String remoteDirectory, String localFile)
            throws IOException {
        File file = resolveLocalPathFor(localFile);
        upload(file, remoteDirectory, file.getName());
    }

    /**
     * Uploads multiple files taken from the current local directory.
     *
     * @param remoteDirectory remote directory path with ending path separator (i.e "/user/home/") or empty line for current directory
     * @param localFiles sources files
     *
     * @throws IOException if an error occurs
     */
    public void mupload(String remoteDirectory, Collection<String> localFiles)
            throws IOException {
        for (String localFile : localFiles) {
            upload(remoteDirectory, localFile);
        }
    }

    /**
     * Uploads multiple files taken from the current local directory.
     *
     * @param remoteDirectory remote directory path with ending path separator (i.e "/user/home/") or empty line for current directory
     * @param localFiles source files
     *
     * @throws IOException if an error occurs
     */
    public void mupload(String remoteDirectory, String... localFiles)
            throws IOException {
        for (String localFile : localFiles) {
            upload(remoteDirectory, localFile);
        }
    }

    /**
     * Uploads multiple files taken from the current local directory.
     *
     * @param remoteDirectory remote directory path with ending path separator (i.e "/user/home/") or empty line for current directory
     * @param localFiles source files
     *
     * @throws IOException if an error occurs
     */
    public void mupload(String remoteDirectory, Enumeration<String> localFiles)
            throws IOException {
        while (localFiles.hasMoreElements()) {
            upload(remoteDirectory, localFiles.nextElement());
        }
    }

    /**
     * Uploads multiple files taken from the current local directory.
     *
     * @param remoteDirectory remote directory path with ending path separator (i.e "/user/home/") or empty line for current directory
     * @param localFiles source files
     *
     * @throws IOException if an error occurs
     */
    public void mupload(String remoteDirectory, Iterator<String> localFiles)
            throws IOException {
        while (localFiles.hasNext()) {
            upload(remoteDirectory, localFiles.next());
        }
    }

    /**
     * Uploads multiple files matching the specified regular expression.
     * Uploads are performed relative to current local directory.  For example, to upload
     * local files with .txt extension use the regular expression ".*\.txt".
     *
     * @param remoteDirectory remote directory path with ending path separator (i.e "/user/home/") or empty line for current directory
     * @param regex source file filter
     *
     * @throws IOException if an error occurs
     */
    public void mupload(String remoteDirectory, String regex)
            throws IOException {
        List<String> files = localFilesFor(regex);
        mupload(remoteDirectory, files.iterator());
    }

    /**
     * Uploads a directory.
     *
     * @param localDirectory source directory
     * @param remoteParentDirectory remote directory path with ending path separator (i.e "/user/home/") or empty line for current directory
     * @param remoteDirectory remote directory name relative to parent remote directory
     *
     * @throws IOException if an error occurs
     */
    public void uploadDir(File localDirectory, String remoteParentDirectory, String remoteDirectory)
            throws IOException {
        try {
            this.service.writeDirectory(localDirectory, remoteParentDirectory, remoteDirectory, null, this.fileAttributesPreservingRequired, new UploadListener());
        } catch (FileService.OperationException e) {
            throw new ScpException(e);
        }
    }

    /**
     * Uploads a directory.
     *
     * @param localDirectory source directory
     * @param remoteParentDirectory remote directory path with ending path separator (i.e "/user/home/") or empty line for current directory
     *
     * @throws IOException if an error occurs
     */
    public void uploadDir(File localDirectory, String remoteParentDirectory)
            throws IOException {
        uploadDir(localDirectory, remoteParentDirectory, localDirectory.getName());
    }

    @Override
    public String toString() {
        return "Scp {"
               + "parameters=" + this.parameters
               + ", configuration=" + this.configuration
               + ", currentLocalDirectory=" + this.currentLocalDirectory
               + ", transferBufferSizeBytes=" + this.transferBufferSizeBytes
               + ", fileAttributesPreservingRequired=" + this.fileAttributesPreservingRequired
               + '}';
    }

    private void assertLicenseValid() {
        
    }

    protected void initService() {
        Connector<SyncRawConnection> rawConnector = rawConnector();
        ClientAuthentication authentication = authentication();
        Connector<SessionConnection> connector = new SessionConnector(rawConnector,
                                                                      this.configuration.identificationString,
                                                                      this.configuration.publicKeyAlgorithms,
                                                                      this.configuration.keyExchangeFactory,
                                                                      this.configuration.encryptionFactory,
                                                                      this.configuration.macFactory,
                                                                      this.configuration.compressionFactory,
                                                                      this.parameters.getHostKeyVerifier(),
                                                                      authentication,
                                                                      this.configuration.initialWindowSize,
                                                                      this.configuration.maxPacketSize,
                                                                      this.logger);
        this.service = new ScpFileService(connector, this.transferBufferSizeBytes, this.logger);
    }

    protected 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);
        }
    }

    protected 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 List<String> localFilesFor(String filter)
            throws IOException {
        final Matcher regex = matcherFor(filter);

        DirectoryStream.Filter<Path> fileFilter = new DirectoryStream.Filter<Path>() {
            @Override
            public boolean accept(Path entry)
                    throws IOException {
                return Files.isRegularFile(entry) && regex.matches(entry.getFileName().toString());
            }
        };

        try (DirectoryStream<Path> directoryStream = Files.newDirectoryStream(this.currentLocalDirectory, fileFilter)) {
            List<String> files = new LinkedList<>();
            for (Path path : directoryStream) {
                files.add(path.getFileName().toString());
            }

            return files;
        }
    }

    private void updateLastModifiedTime(File localFile, Long fileModificationTime) {
        if (!this.fileAttributesPreservingRequired || fileModificationTime == null) {
            return;
        }

        try {
            Files.setLastModifiedTime(localFile.toPath(), FileTime.fromMillis(fileModificationTime));
        } catch (Exception e) {
            // ignore
        }
    }

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

    private Matcher matcherFor(String filter) {
        try {
            return StringTools.isValidString(filter) ? REGEX_FACTORY.matcherFor(filter) : Matcher.POSITIVE;
        } catch (Exception e) {
            throw new IllegalArgumentException(e);
        }
    }

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

    private ScpException wrap(Throwable e) {
        Throwable cause = e.getCause();

        return cause != null && cause.getCause() instanceof ClientAuthentication.InvalidCredentialsException
               ? new ScpAuthenticationException()
               : new ScpException(e);
    }

    private class DownloadListener
            implements FileService.TransferListener {

        private String filename;
        private long totalBytes;
        private long startTime;

        @Override
        public void onTransferStarted(String filename, long totalBytes) {
            this.filename = filename;
            this.totalBytes = totalBytes;
            this.startTime = Clock.now();
        }

        @Override
        public void onTransferProgress(long transferredBytes) {
            raiseEvent(new ScpTransferProgressEvent(Scp.this,
                                                    this.filename,
                                                    transferredBytes,
                                                    this.totalBytes,
                                                    ScpTransferProgressEvent.DOWNLOAD));
        }

        @Override
        public void onTransferCompleted(long totalBytes) {
            raiseEvent(new ScpFileDownloadedEvent(Scp.this,
                                                  this.filename,
                                                  totalBytes,
                                                  Clock.elapsedSince(this.startTime)));
        }

    }

    private class UploadListener
            implements FileService.TransferListener {

        private String filename;
        private long totalBytes;
        private long startTime;

        @Override
        public void onTransferStarted(String filename, long totalBytes) {
            this.filename = filename;
            this.totalBytes = totalBytes;
            this.startTime = Clock.now();
        }

        @Override
        public void onTransferProgress(long transferredBytes) {
            raiseEvent(new ScpTransferProgressEvent(Scp.this,
                                                    this.filename,
                                                    transferredBytes,
                                                    this.totalBytes,
                                                    ScpTransferProgressEvent.UPLOAD));
        }

        @Override
        public void onTransferCompleted(long totalBytes) {
            raiseEvent(new ScpFileUploadedEvent(Scp.this,
                                                this.filename,
                                                this.totalBytes,
                                                Clock.elapsedSince(this.startTime)));
        }

    }

}
