/*
 * @(#)FtpsClient.java
 *
 * Copyright (c) 2004 JSCAPE
 * 1147 S. 53rd Pl., Mesa, Arizona, 85206, U.S.A.
 * 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.ftps;

import com.jscape.inet.ftp.FtpCommandEvent;
import com.jscape.inet.ftp.FtpException;
import com.jscape.inet.ftp.FtpResponseEvent;
import com.jscape.inet.util.ConnectionParameters;
import com.jscape.inet.util.ServerSocketUtil;
import com.jscape.util.Assert;
import com.jscape.util.IOTools;
import com.jscape.util.StringTools;
import com.jscape.util.Timeout;
import com.jscape.util.net.ssl.SslContextFactory;

import javax.net.ssl.*;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.StringTokenizer;
import java.util.Vector;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * The FTP client. Provides protocol-level services hiding the exact
 * FTP request/response exchange implementation.
 */
public class FtpsClient {

    private static final int IP4_PACKET_LENGTH = 4;

    private static final int IP6_PACKET_LENGTH = 16;

    /**
     * The FTP default port
     */
    public static final int DEFAULT_PORT = 21;

    /**
     * The FTP data default port
     */
    public static final int DEFAULT_DATA_PORT = 0;

    /** */
    public static final char DATA_CLEAR = 'C';

    /** */
    public static final char DATA_SAFE = 'S';

    /** */
    public static final char DATA_CONFIDENTIAL = 'E';

    /** */
    public static final char DATA_PRIVATE = 'P';

    /**
     * The FTP end-of-line symbols
     */
    public static final String EOL = "\r\n";

    private final Ftps source;
    private final ConnectionParameters connectionParameters;
    private final SSLContext sslContext;
    private final String[] enabledCiphers;
    private final Logger logger;
    private Socket controlSocket;
    private Socket plainControlSocket;
    private Socket sslSocket;
    private DataConnector dataConnector;
    private boolean dataProtected;
    private boolean useEPSV;
    private boolean useEPRT;
    private String portAddress;
    private String serverHost;
    private boolean shutdownCCC;
    private boolean connectBefore;
    private FtpsCertificateVerifier certificateVerifier;
    private Response lastResponse;
    private int sendBufferSize;
    private int receiveBufferSize;
    private String ipNATAddress;
    private boolean autoDetectIpv6;
    private HandshakeCompletedListener sslHandshakeCompletedListener;

    public static FtpsClient openPlain(Ftps source,
                                       ConnectionParameters connectionParameters,
                                       SSLContext sslContext,
                                       String[] enabledCiphers,
                                       Logger logger)
            throws FtpException {
        FtpsClient client = new FtpsClient(source, connectionParameters, sslContext, enabledCiphers, logger);
        client.open(false);

        return client;
    }

    public static FtpsClient openProtected(Ftps source,
                                           ConnectionParameters connectionParameters,
                                           SSLContext sslContext,
                                           String[] enabledCiphers,
                                           Logger logger)
            throws FtpException {
        FtpsClient client = new FtpsClient(source, connectionParameters, sslContext, enabledCiphers, logger);
        client.open(true);

        return client;
    }

    private FtpsClient(Ftps source,
                       ConnectionParameters connectionParameters,
                       SSLContext sslContext,
                       String[] enabledCiphers,
                       Logger logger) {
        this.source = source;
        this.connectionParameters = connectionParameters;
        this.sslContext = sslContext;
        this.enabledCiphers = enabledCiphers;
        this.logger = logger;

        this.useEPSV = this.source.getUseEPSV();
        this.useEPRT = this.source.getUseEPRT();
        this.autoDetectIpv6 = this.source.getAutoDetectIpv6();
        this.certificateVerifier = this.source.getFtpsCertificateVerifier();
        this.shutdownCCC = this.source.getShutdownCCC();
        this.sslHandshakeCompletedListener = this.source.getSslHandshakeCompletedListener();
    }

    private void open(boolean protect)
            throws FtpException {
        try {
            this.controlSocket = this.connectionParameters.createSocket();

            if (protect) {
                this.controlSocket = protectSocket(this.controlSocket, true);
            }

            if (this.serverHost != null) {
                this.dataConnector = new PassiveConnector(this,
                                                          this.source.getHostname(),
                                                          this.connectionParameters.getHost(),
                                                          DEFAULT_DATA_PORT,
                                                          this.source.getTimeout(),
                                                          this.connectionParameters.getTcpNoDelay(),
                                                          protect,
                                                          this.connectBefore);
            } else {
                this.dataConnector = new PassiveConnector(this,
                                                          this.connectionParameters.getHost(),
                                                          this.source.getHostname(),
                                                          DEFAULT_DATA_PORT,
                                                          this.source.getTimeout(),
                                                          this.connectionParameters.getTcpNoDelay(),
                                                          protect,
                                                          this.connectBefore);
            }

            this.dataProtected = protect;

            setSocketTimeout(this.controlSocket);

            readResponse(); // skip starting server responses
        } catch (Exception e) {
            throw new FtpException(e.getMessage(), e);
        }
    }

    /**
     * Closes the current client.
     */
    public void close() {
        IOTools.close(this.controlSocket);
        IOTools.close(this.plainControlSocket);

        if (this.dataConnector != null) {
            this.dataConnector.close();
        }

        this.controlSocket = null;
        this.plainControlSocket = null;
        this.dataConnector = null;
    }

    /**
     * Set auto detect Ipv6 detection mode.
     *
     * @param value a flag
     */
    public void setAutoDetectIpv6(boolean value) {
        this.autoDetectIpv6 = value;
    }

    /**
     * Get auto detect Ipv6 detection mode.
     *
     * @return value
     */
    public boolean getAutoDetectIpv6() {
        return this.autoDetectIpv6;
    }

    /**
     * Checks if the current client is closed.
     *
     * @return <code>true</code> if the client is closed;
     *         <code>false</code> otherwise
     */
    public boolean isClosed() {
        return this.controlSocket == null;
    }

    /**
     * Ensures that the current client is not closed.
     *
     * @throws IllegalStateException if the client is already closed
     */
    private void ensureNotClosed() {
        if (isClosed()) {
            throw new IllegalStateException("client is closed");
        }
    }

    /**
     * Verifies if NAT address is valid.
     *
     * @param ipAddress The will be verifies
     *
     * @return <code>true</code> if the address contains a valid format
     */
    private boolean isValidFormatIP(String ipAddress) {
        StringTokenizer tok = new StringTokenizer(ipAddress.substring(0, ipAddress.length()), ".");
        return tok.countTokens() == 4;
    }

    /**
     * Gets the external address to use when using active connections
     * using the PORT command.
     *
     * @return the external ip address
     */
    public String getPortAddress() {
        return this.portAddress;
    }

    /**
     * Sets the external address to use when using active connections
     * using the PORT command.
     *
     * @param string an ip address
     */
    public void setPortAddress(String string) {
        this.portAddress = string;
    }

    /**
     * Sets the connectBefore property value
     *
     * @param connect It indicates if the connection will be established before
     *                send the command
     */
    public void setConnectBeforeCommand(boolean connect) {
        this.connectBefore = connect;
        if (this.dataConnector != null && this.dataConnector instanceof PassiveConnector) {
            ((PassiveConnector) this.dataConnector).setConnectBeforeCommand(connect);
        }
    }

    /**
     * Sets a NAT address which will be used after PASV command.
     *
     * @param ipAddress The NAT IP address.
     *
     * @throws FtpException If an error occurs.
     */
    public void setNATAddress(String ipAddress)
            throws FtpException {
        if (isValidFormatIP(ipAddress)) {
            this.ipNATAddress = ipAddress;
        } else {
            throw new FtpException("Invalid NAT IP address format");
        }
    }

    /**
     * Get NAT address.
     *
     * @return NAT address
     */
    public String getNATAddress() {
        return this.ipNATAddress;
    }

    /**
     * Returns the current SSL handshake listener.
     *
     * @return the current SSL handshake listener
     */
    public HandshakeCompletedListener getSslHandshakeCompletedListener() {
        return this.sslHandshakeCompletedListener;
    }

    /**
     * Sets a current SSL handshake listener.
     *
     * @param value a current SSL handshake listener
     */
    public void setSslHandshakeCompletedListener(HandshakeCompletedListener value) {
        this.sslHandshakeCompletedListener = value;
    }

    /**
     * Gets the connectBefore indicator
     *
     * @return <code>true</code> the connection will be establiched before send the command
     */
    public boolean getConnectBeforeCommand() {
        return this.connectBefore;
    }

    /**
     * Sends the request to the server.
     *
     * @param command a FTP command
     *
     * @return the server response
     *
     * @throws FtpException if an I/O or protocol error occurs
     */
    public Response sendRequest(String command)
            throws FtpException {
        ensureNotClosed();
        sendCommand(command);

        return readResponse();
    }

    /**
     * Obtains the last server response.
     *
     * @return the last server response
     */
    public Response getLastResponse() {
        return this.lastResponse;
    }

    /**
     * Sends command to the server.
     *
     * @param command a FTP command
     *
     * @throws FtpException if an I/O or protocol error occurs
     */
    public void sendCommand(String command)
            throws FtpException {
        Assert.notNull(command);

        try {
            OutputStream out = this.controlSocket.getOutputStream();

            if (this.source.getWireEncoding() != null) {
                out.write((command + EOL).getBytes(Charset.forName(this.source.getWireEncoding())));
            } else {
                out.write((command + EOL).getBytes(StandardCharsets.UTF_8));
            }

            out.flush();

            this.source.fireEvent(new FtpCommandEvent(this.source, command));

            if (command != null && command.startsWith("PASS")) {
                this.logger.info("PASS ******");
            } else {
                this.logger.info(command);
            }
        } catch (Exception e) {
            this.logger.log(Level.WARNING, "Error sending FTP command.", e);
            throw new FtpException(e.getMessage(), e);
        }
    }

    /**
     * Reads the server response skipping all preliminary responses.
     *
     * @return the server response
     *
     * @throws FtpException if an I/O or protocol error occurs
     */
    public Response readResponse()
            throws FtpException {
        try {
            BufferedReader in;

            if (this.source.getWireEncoding() != null) {
                in = new BufferedReader(new InputStreamReader(this.controlSocket.getInputStream(),
                                                              this.source.getWireEncoding()));
            } else {
                in = new BufferedReader(new InputStreamReader(this.controlSocket.getInputStream()));
            }

            Response response;

            for (response = Response.readFrom(in);
                 response.isPreliminary();
                 response = Response.readFrom(in)) {
                this.logger.info(response.toString());
            }

            source.fireEvent(new FtpResponseEvent(source, response.toString()));
            this.logger.info(response.toString());
            this.lastResponse = response;
            assertNoError(response);
            return response;
        } catch (Exception e) {
            throw new FtpException(e.getMessage(), e);
        }
    }

    /**
     * Checks if the returned response is not negative.
     *
     * @param response the reposense to check
     *
     * @throws FtpException if the message is negative
     */
    private static void assertNoError(Response response)
            throws FtpException {
        if (response.isNegative()) {
            throw new FtpException(response.toString());
        }
    }

    /**
     * Gets whether SSL connection should be shutdown after successfully issuing
     * CCC command.  Default is true.
     *
     * @return <code>true</code> to shutdown, <code>false</code> otherwise
     */
    public boolean isShutdownCCC() {
        return this.shutdownCCC;
    }

    /**
     * Sets whether SSL connection should be shutdown after successfully issuing
     * CCC command.  Default is true.
     *
     * @param shutdownCCC <code>true</code> to shutdown, <code>false</code> otherwise
     */
    public void setShutdownCCC(boolean shutdownCCC) {
        this.shutdownCCC = shutdownCCC;
    }

    /**
     * Sets use EPSV indicator. When it is true then will be sent EPSV.
     *
     * @param useEPSV The use EPSV value
     */
    public void setUseEPSV(boolean useEPSV) {
        this.useEPSV = useEPSV;
    }

    /**
     * Gets use EPSV value.
     *
     * @return <code>true</code> the pasive mode will be sent EPSV command
     */
    public boolean getUseEPSV() {
        return this.useEPSV;
    }

    /**
     * Sets use EPRT indicator. When it is true then will be sent EPRT.
     *
     * @param useEPRT The use EPRT value
     */
    public void setUseEPRT(boolean useEPRT) {
        this.useEPRT = useEPRT;
    }

    /**
     * Gets use EPRT value.
     *
     * @return <code>true</code> the active mode will be sent EPRT command
     */
    public boolean getUseEPRT() {
        return this.useEPRT;
    }

    //--------------------------------------------------------------------------
    // FTP COMMAND WRAPPERS
    //--------------------------------------------------------------------------

    /**
     * The FTP USER command.
     *
     * @param name user name
     *
     * @return the server response which may be used for additional
     *         authentication
     *
     * @throws FtpException if an I/O or protocol error occurs
     */
    public Response userName(String name)
            throws FtpException {
        Assert.notNull(name);

        return sendRequest("USER " + name);
    }

    /**
     * The FTP PASS command.
     *
     * @param password the password
     *
     * @return the server response which may be used for additional
     *         authentication
     *
     * @throws FtpException if an I/O or protocol error occurs
     */
    public Response password(String password)
            throws FtpException {
        Assert.notNull(password);

        return sendRequest("PASS " + password);
    }

    /**
     * The FTP ACCT command.
     *
     * @param account the user account
     *
     * @throws FtpException if an I/O or protocol error occurs
     */
    public void account(String account)
            throws FtpException {
        sendRequest("ACCT " + account);
    }

    /**
     * The FTP CWD command.
     *
     * @param directory the target directory.
     *
     * @throws FtpException if an I/O or protocol error occurs
     */
    public void changeWorkingDirectory(String directory)
            throws FtpException {
        Assert.notNull(directory);
        sendRequest("CWD " + directory);
    }

    /**
     * The FTP CDUPcommand.
     *
     * @throws FtpException if an I/O or protocol error occurs
     */
    public void changeToParentDirectory()
            throws FtpException {
        sendRequest("CDUP");
    }

    /**
     * The FTP SMNT command.
     *
     * @param structure the structure
     *
     * @throws FtpException if an I/O or protocol error occurs
     */
    public void structureMount(String structure)
            throws FtpException {
        Assert.notNull(structure);
        sendRequest("SMNT " + structure);
    }

    /**
     * The FTP REIN command.
     *
     * @throws FtpException if an I/O or protocol error occurs
     */
    public void reinitialize()
            throws FtpException {
        sendRequest("REIN");
    }

    /**
     * The FTP QUIT command.
     *
     * @throws FtpException if an I/O or protocol error occurs
     */
    public void quit()
            throws FtpException {
        try {
            sendRequest("QUIT");
        } catch (FtpException e) {
            // ignore .. some servers do not return response
        }
    }

    /**
     * Checks to see if address is in Ipv6 format
     *
     * @param address
     *
     * @return boolean value
     */
    private boolean isIpv6Address(String address) {

        /*
           * Check for Ipv4 addressses mapped to Ipv6 addresses
           * For example ::FFFF:129.144.52.38
                         ::129.144.52.38
           */
        if (address.startsWith("::") || address.startsWith("::FFFF:") || address.startsWith("::ffff:")) {
            String ipv4Address = address.substring(address.indexOf(".") - 3);
            StringTokenizer ipTokenizer = new StringTokenizer(ipv4Address.substring(0, ipv4Address.length()), ".");
            if (ipTokenizer.countTokens() == 4) {
                return true;
            }
        }

        Pattern pattern = Pattern.compile("::");
        Matcher matcher = pattern.matcher(address);
        int doubleColons = 0;

        while (matcher.find()) {
            doubleColons++;
        }

        // Must have only one instance of :: in the entire IP string
        if (doubleColons > 1) {
            return false;
        }

        StringTokenizer tokenizer = new StringTokenizer(address, ":", false);
        Vector<String> parts = new Vector<String>();

        while (tokenizer.hasMoreElements()) {
            parts.add(tokenizer.nextToken());
        }

        // Must have only eight parts.
        if (doubleColons == 0 && parts.size() != 8) {
            return false;
        }

        // Now check whether each character of each part is a valid hex character
        pattern = Pattern.compile("([a-fA-F]?)(\\d?)");
        for (int i = 0; i < parts.size(); i++) {
            String entry = parts.get(i);
            for (int count = 0; count < entry.length(); count++) {
                if (pattern.matcher(Character.toString(entry.charAt(count))).find() == false) {
                    return false;
                }
            }
        }

        return true;
    }


    /*
    * Use Active mode, either the PORT or the EPRT command will be used
    */

    private void useActiveFtpMode()
            throws FtpException {
        ActiveModeData hostData = getHostData();

        if (this.useEPRT == true || this.autoDetectIpv6 == true) {
            boolean isIpv6Address = this.isIpv6Address(hostData.getHostname());
            if (isIpv6Address == true) {
                this.logger.fine("Ipv6 hostname/IP detected : " + hostData.getHostname());
            }

            this.useEPRT = (isIpv6Address || this.source.isFeatureSupported("EPRT")) && hostData.isEPRTRequired();
        }

        if (this.useEPRT) {
            sendEPRTCommand(hostData.getCommandData());
        } else {
            sendPORTCommand(hostData.getCommandData());
        }
    }

    /*
    * Try sending the EPRT command
    */

    private void sendEPRTCommand(String hostData)
            throws FtpException {
        try {
            sendRequest("EPRT " + hostData);
        } catch (FtpException e) {
            throw e;
        }
    }

    /*
    * Try sending the PORT command
    */

    private void sendPORTCommand(String hostData)
            throws FtpException {
        try {
            sendRequest("PORT " + hostData);
        } catch (FtpException e) {
            throw e;
        }
    }

    //--------------------------------------------------------------------------

    /**
     * Prepares host data for "PORT" or the "EPRT" command.
     *
     * @return string with host data.
     *
     * @throws FtpException if current host cannot be determined.
     */
    private ActiveModeData getHostData()
            throws FtpException {
        ActiveModeData ret = null;
        byte[] nic = this.getLocalNICInterface();
        int length = nic.length;

        if (length == IP6_PACKET_LENGTH && this.useEPRT == false) {
            throw new FtpException("Client is on an IPv6 network but EPRT command is not supported by server.");
        } else if (length == IP6_PACKET_LENGTH && this.useEPRT == true) {
            ret = this.getEPRTHostDataInIpv6Format(nic); // IPv6 and EPRT
        } else if (length == IP4_PACKET_LENGTH && this.useEPRT == true) {
            ret = this.getEPRTHostDataInIpv4Format(nic); // IPv4 and EPRT
        } else if (length == IP4_PACKET_LENGTH && this.useEPRT == false) {
            ret = this.getPORTHostData(nic);             // IPv4 and PORT
        }
        return ret;
    }

    private ActiveModeData getPORTHostData(byte[] nic) {
        String result = "";
        for (int i = 0; i < nic.length; ++i) {
            result += (nic[i] & 0xff) + ",";
        }

        int port = ((ActiveConnector) this.dataConnector).getLocalPort();
        return new ActiveModeData(result,
                                  (result + Integer.toString((port >> 8) & 0xff) + "," + Integer.toString(port
                                                                                                                  & 0xff)));
    }

    private ActiveModeData getEPRTHostDataInIpv4Format(byte[] nic) {
        String result = "";
        for (int i = 0; i < nic.length; i++) {
            result += (nic[i] & 0xff) + ".";
        }
        String host = result.substring(0, result.length() - 1);
        return new ActiveModeData(host, "|1|" + host + "|" + getPortNumberFromSocketConnection() + "|");
    }

    private ActiveModeData getEPRTHostDataInIpv6Format(byte[] nic) {
        String result = "";
        for (int i = 0; i < nic.length; i += 2) {
            String firstByte = Integer.toString((nic[i] & 0xff) + 0x100, 16).substring(1);
            String secondByte = Integer.toString((nic[i + 1] & 0xff) + 0x100, 16).substring(1);
            result += firstByte + secondByte + ":";
        }
        String host = result.substring(0, result.length() - 1);
        result = "|2|" + result + "|" + getPortNumberFromSocketConnection() + "|";
        return new ActiveModeData(host, result);
    }

    private int getPortNumberFromSocketConnection() {
        int port = ((ActiveConnector) this.dataConnector).getLocalPort();
        String str1 = Integer.toString((port >> 8) & 0xff);
        String str2 = Integer.toString(port & 0xff);
        int a = Integer.parseInt(str1);
        int b = Integer.parseInt(str2);

        return (a * 256) + b;
    }

    /**
     * The FTP PORT command.
     *
     * @param range data port range
     *
     * @throws FtpException if an I/O or protocol error occurs
     */
    public void dataPort(ServerSocketUtil.PortRange range)
            throws FtpException {
        this.dataConnector.close();
        this.dataConnector = new ActiveConnector(range, this.dataProtected, this.sslContext);
        this.useActiveFtpMode();
    }


    private byte[] getLocalNICInterface()
            throws FtpException {
        byte[] addr = null;

        // gets the local NIC interface that is currently bound to command connection
        try {

            if (getPortAddress() != null) {
                addr = InetAddress.getByName(getPortAddress()).getAddress();
            } else {
                addr = this.controlSocket.getLocalAddress().getAddress();
            }
        } catch (Exception ex) {
            throw new FtpException(ex.getMessage(), ex);
        }
        return addr;
    }

    /**
     * The FTP PASV command.
     *
     * @throws FtpException if an I/O or protocol error occurs
     */
    public void passive(boolean firewall)
            throws FtpException {
        Response response;

        if (this.useEPSV == true || this.autoDetectIpv6 == true) {
            boolean isIpv6Address = this.isIpv6Address(source.getHostname());
            boolean isIpv4Address = this.isValidFormatIP(source.getHostname());
            if (!isIpv6Address && !isIpv4Address) {
                try {
                    AsyncReverseDNSLookup addrLookup = new AsyncReverseDNSLookup(source.getHostname());
                    Timeout timeout = new Timeout(1000L);
                    addrLookup.start();

                    while (!timeout.isExceeded()) {
                        Thread.sleep(1L);
                        if (addrLookup.lookupComplete) {
                            this.source.setHostname(addrLookup.hostname, false);
                            this.logger.fine("Reverse DNS address lookup completed in " + (timeout.getValue() - timeout.remainedTime()) + " ms [" + source.getHostname() + "]");
                            break;
                        }
                    }

                    if (addrLookup.lookupComplete == false) {
                        String message = "Reverse DNS lookup unsuccessful ";
                        if (timeout.isExceeded()) {
                            message += "(timed out)";
                        }
                        this.logger.fine(message);
                    }

                    isIpv6Address = this.isIpv6Address(source.getHostname());
                } catch (Exception e) {
                    // ignore
                }
            }
            this.useEPSV = isIpv6Address || this.source.isFeatureSupported("EPSV");
        }


        if (!this.useEPSV) {
            response = sendRequest("PASV");
        } else {
            response = sendRequest("EPSV");
        }

        // todo: refactor and test for robustness
        String text = response.getMessage();
        String host = "";
        int port;
        if (!this.useEPSV) {
            if (text.indexOf('(') == -1) {
                StringTokenizer tok = new StringTokenizer(text, ",");
                if (tok.countTokens() != 6) {
                    throw new FtpException("Wrong host & port for connection");
                }
            } else {
                text = text.substring(text.indexOf('(') + 1, text.indexOf(')'));
            }

            if (StringTools.isValidString(this.ipNATAddress)) {
                if (isValidFormatIP(this.ipNATAddress)) {
                    host = this.ipNATAddress;
                    for (int i = 0; i < 4; i++) {
                        text = text.substring(text.indexOf(',') + 1, text.length());
                    }
                } else {
                    throw new FtpException("Invalid NAT IP address format");
                }
            } else {
                for (int i = 0; i < 4; i++) {
                    host += text.substring(0, text.indexOf(','));
                    text = text.substring(text.indexOf(',') + 1, text.length());

                    if (i != 3) {
                        host += ".";
                    }
                }
            }
            port = 256 * (Integer.parseInt(text.substring(0, text.indexOf(','))))
                    + Integer.parseInt(text.substring(text.indexOf(',') + 1, text.length()));
        } else {
            InetAddress address = this.controlSocket.getInetAddress();
            String hostAddress = address.getHostAddress();
            host = hostAddress;
            String portData = parseEPSVResponse(text);
            port = Integer.parseInt(portData);
        }

        if (serverHost != null) {
            if (this.connectionParameters.getProxyHost() == null) {
                this.dataConnector = new PassiveConnector(this,
                                                          source.getHostname(),
                                                          host,
                                                          port,
                                                          source.getTimeout(),
                                                          this.connectionParameters.getTcpNoDelay(),
                                                          this.dataProtected,
                                                          this.connectBefore);
            } else {
                this.dataConnector = new PassiveConnector(this,
                                                          source.getHostname(),
                                                          host,
                                                          port,
                                                          source.getTimeout(),
                                                          this.connectionParameters.getTcpNoDelay(),
                                                          this.dataProtected,
                                                          this.connectBefore,
                                                          this.connectionParameters.getProxyHost(),
                                                          this.connectionParameters.getProxyPort(),
                                                          this.connectionParameters.getProxyUserId(),
                                                          this.connectionParameters.getProxyPassword(),
                                                          this.connectionParameters.getProxyType());

            }
        } else {
            if (this.connectionParameters.getProxyHost() == null) {
                this.dataConnector = new PassiveConnector(this,
                                                          host,
                                                          source.getHostname(),
                                                          port,
                                                          source.getTimeout(),
                                                          this.connectionParameters.getTcpNoDelay(),
                                                          this.dataProtected,
                                                          this.connectBefore);
            } else {
                this.dataConnector = new PassiveConnector(this,
                                                          host,
                                                          source.getHostname(),
                                                          port,
                                                          source.getTimeout(),
                                                          this.connectionParameters.getTcpNoDelay(),
                                                          this.dataProtected,
                                                          this.connectBefore,
                                                          this.connectionParameters.getProxyHost(),
                                                          this.connectionParameters.getProxyPort(),
                                                          this.connectionParameters.getProxyUserId(),
                                                          this.connectionParameters.getProxyPassword(),
                                                          this.connectionParameters.getProxyType());
            }
        }
    }


    /**
     * The FTP TYPE command.
     *
     * @param mode the transfer mode
     *
     * @throws FtpException if an I/O or protocol error occurs
     */
    public void transferMode(String mode)
            throws FtpException {
        Assert.notNull(mode);
        sendRequest("MODE " + mode);
    }

    /**
     * The FTP TYPE command.
     *
     * @param type the representation type
     *
     * @throws FtpException if an I/O or protocol error occurs
     */
    public void representationType(String type)
            throws FtpException {
        Assert.notNull(type);
        sendRequest("TYPE " + type);
    }

    /**
     * The FTP STRU command.
     *
     * @param fileStructure the file structure
     *
     * @throws FtpException if an I/O or protocol error occurs
     */
    public void fileStructure(char fileStructure)
            throws FtpException {
        sendRequest("STRU " + fileStructure);
    }

    /**
     * The FTP ALLO command.
     *
     * @param size the size
     *
     * @throws FtpException if an I/O or protocol error occurs
     */
    public void allocate(int size)
            throws FtpException {
        sendRequest("ALLO " + size);
    }

    /**
     * The FTP ALLO command.
     *
     * @param size        the size
     * @param optionsSize the options size
     *
     * @throws FtpException if an I/O or protocol error occurs
     */
    public void allocate(int size, int optionsSize)
            throws FtpException {
        sendRequest("ALLO " + size + " R " + optionsSize);
    }

    /**
     * The FTP REST command.
     *
     * @param serverMarker the server marker string
     *
     * @throws FtpException if an I/O or protocol error occurs
     */
    public void restart(String serverMarker)
            throws FtpException {
        sendRequest("REST " + serverMarker);
    }

    /**
     * The FTP RETR command. This command needs to read final response after
     * reading the data.
     *
     * @param fileName the target file path
     *
     * @return the opened data socket
     *
     * @throws FtpException if an I/O or protocol error occurs
     */
    public Socket retrieve(String fileName)
            throws FtpException {
        Assert.notNull(fileName, "Remote filename may not be null.");
        return this.dataConnector.openConnection("RETR " + fileName);
    }

    /**
     * The FTP STOR command. This command needs to read final response after
     * reading the data.
     *
     * @param fileName the target file path
     *
     * @return the opened data socket
     *
     * @throws FtpException if an I/O or protocol error occurs
     */
    public Socket store(String fileName)
            throws FtpException {
        Assert.notNull(fileName, "Remote filename may not be null.");
        return this.dataConnector.openConnection("STOR " + fileName);
    }

    /**
     * The FTP STOU command. This command needs to read final response after
     * reading the data.
     *
     * @param fileName a file name
     *
     * @return the opened data socket
     *
     * @throws FtpException if an I/O or protocol error occurs
     */
    public Socket storeUnique(String fileName)
            throws FtpException {
        if (fileName != null) {
            return this.dataConnector.openConnection("STOU " + fileName);
        } else {
            return this.dataConnector.openConnection("STOU");
        }

    }

    /**
     * The FTP APPE command. This command needs to read final response after
     * reading the data.
     *
     * @param fileName the target file path
     *
     * @return the opened data socket
     *
     * @throws FtpException if an I/O or protocol error occurs
     */
    public Socket append(String fileName)
            throws FtpException {
        return this.dataConnector.openConnection("APPE " + fileName);
    }

    /**
     * The FTP RNFR and RNTO commands.
     *
     * @param oldName the old file name
     * @param newName the new file name
     *
     * @throws FtpException if an I/O or protocol error occurs
     */
    public void rename(String oldName, String newName)
            throws FtpException {
        Assert.notNull(oldName);
        Assert.notNull(newName);

        sendRequest("RNFR " + oldName);
        sendRequest("RNTO " + newName);
    }

    /**
     * The FTP ABOR command.
     *
     * @throws FtpException if an I/O or protocol error occurs
     */
    public void abort()
            throws FtpException {
        sendRequest("ABOR");
    }

    /**
     * The FTP DELE command.
     *
     * @param fileName the target file name
     *
     * @throws FtpException if an I/O or protocol error occurs
     */
    public void delete(String fileName)
            throws FtpException {
        Assert.notNull(fileName);
        sendRequest("DELE " + fileName);
    }

    /**
     * The FTP RMD command.
     *
     * @param directoryName the target directory name
     *
     * @throws FtpException if an I/O or protocol error occurs
     */
    public void removeDirectory(String directoryName)
            throws FtpException {
        Assert.notNull(directoryName);
        sendRequest("RMD " + directoryName);
    }

    /**
     * The FTP MKD command.
     *
     * @param directoryName the target directory name
     *
     * @throws FtpException if an I/O or protocol error occurs
     */
    public void makeDirectory(String directoryName)
            throws FtpException {
        Assert.notNull(directoryName);
        sendRequest("MKD " + directoryName);
    }

    /**
     * The FTP PWD command.
     *
     * @return the server resopnse
     *
     * @throws FtpException if an I/O or protocol error occurs
     */
    public String printWorkingDirectory()
            throws FtpException {
        return sendRequest("PWD").getMessage();
    }

    /**
     * The FTP LIST command. This command needs to read final response after
     * reading the data.
     *
     * @param path the directory path
     *
     * @return the opened data socket
     *
     * @throws FtpException if an I/O or protocol error occurs
     */
    public Socket list(String path)
            throws FtpException {
        Assert.notNull(path);

        String command = "LIST" + (path.length() > 0 ? " " + path : "");

        return this.dataConnector.openConnection(command);
    }

    /**
     * The FTP NLST command. This command needs to read final response after
     * reading the data.
     *
     * @param path the directory path
     *
     * @return the opened data connection
     *
     * @throws FtpException if an I/O or protocol error occurs
     */
    public Socket nameList(String path)
            throws FtpException {
        Assert.notNull(path);
        String command = "NLST";
        command += (path.length() > 0) ? " " + path : "";
        return this.dataConnector.openConnection(command);
    }


    /**
     * The FTP SITE command.
     *
     * @return the server response
     *
     * @throws FtpException if an I/O or protocol error occurs
     */
    public String siteParameters()
            throws FtpException {
        return sendRequest("SITE").getMessage();
    }

    /**
     * The FTP SYST command.
     *
     * @return the server response
     *
     * @throws FtpException if an I/O or protocol error occurs
     */
    public String system()
            throws FtpException {
        return sendRequest("SYST").getMessage();
    }

    /**
     * The FTP STAT command.
     *
     * @param path the file path
     *
     * @return the server response
     *
     * @throws FtpException if an I/O or protocol error occurs
     */
    public String status(String path)
            throws FtpException {
        return sendRequest("STAT " + path).getMessage();
    }

    /**
     * The FTP HELP command.
     *
     * @param option the command option
     *
     * @return the server response
     *
     * @throws FtpException if an I/O or protocol error occurs
     */
    public String help(String option)
            throws FtpException {
        return sendRequest("HELP " + option).getMessage();
    }

    /**
     * The FTP NOOP command.
     *
     * @return the server response
     *
     * @throws FtpException if an I/O or protocol error occurs
     */
    public Response noop()
            throws FtpException {
        return sendRequest("NOOP");
    }

    /**
     * The FTP AUTH TLS command.
     *
     * @param method an authorization method
     *
     * @throws FtpException if an I/O or protocol error occurs
     */
    public void authorize(String method)
            throws FtpException {
        sendRequest("AUTH " + method);
        this.plainControlSocket = this.controlSocket;
        this.controlSocket = protectSocket(this.controlSocket, false);
    }

    private String parseEPSVResponse(String data)
            throws FtpException {
        int start = 0;
        int end = 0;
        if (data.indexOf("(") != -1) {
            start = data.indexOf("(");
            end = data.indexOf(")");
        } else if (data.indexOf("|") != -1) {
            start = data.indexOf("|");
            end = data.length();
        } else {
            throw new FtpException("Invalid EPSV response");
        }

        String responseData = data.substring(start + 1, end);
        StringTokenizer portToken = new StringTokenizer(responseData.substring(0,
                                                                               responseData.length()), "|");
        return portToken.nextToken();
    }

    private Socket protectSocket(Socket s, boolean owner)
            throws FtpException {
        SSLSocketFactory factory = this.sslContext.getSocketFactory();
        String host = s.getInetAddress().getHostAddress();
        int port = s.getPort();

        try {
            SSLSocket sslSocket = (SSLSocket) factory.createSocket(s, host, port, owner);
            sslSocket.setUseClientMode(true);

            if (this.certificateVerifier != null && !this.certificateVerifier.authorized()) {
                this.certificateVerifier.verify(sslSocket.getSession());
            }

            if (this.enabledCiphers != null) {
                sslSocket.setEnabledCipherSuites(this.enabledCiphers);
            }

            if (this.sslHandshakeCompletedListener != null) {
                sslSocket.addHandshakeCompletedListener(this.sslHandshakeCompletedListener);
            }

            return sslSocket;
        } catch (IOException e) {
            throw new FtpException(e.getMessage(), e);
        }
    }

    /**
     * The FTP CCC command.
     *
     * @throws FtpException if an I/O or protocol error occurs
     */
    public void clear()
            throws FtpException {
        sendRequest("CCC");
        unprotectControlSocket();
    }


    /**
     * Unprotects the control socket.
     *
     * @throws FtpException if an I/O or protocol error occurs
     */
    private void unprotectControlSocket()
            throws FtpException {
        if (this.shutdownCCC) {
            if (this.controlSocket instanceof SSLSocket) {
                try {
                    this.controlSocket.close();
                } catch (Exception e) {
                    throw new FtpException(e.getMessage());
                }
            }
        }

        if (this.plainControlSocket != null) {
            try {
                preventSslAlertWhileGC();
                this.controlSocket = this.plainControlSocket;
            } catch (Exception e) {
                throw new FtpException(e.getMessage(), e);
            }
        }
    }

    private void preventSslAlertWhileGC() {
        if (this.controlSocket instanceof SSLSocket) {
            this.sslSocket = this.controlSocket;
        }
    }

    /**
     * The FTP PBSZ 0 command.
     *
     * @throws FtpException if an I/O or protocol error occurs
     */
    public void bufferSize()
            throws FtpException {
        try {
            sendRequest("PBSZ 0");
        } catch (FtpException fe) {
            if (!fe.getMessage().startsWith("502")) {
                throw fe;
            }
        }
    }

    /**
     * The FTP PROT command.
     *
     * @param level the protection level
     *
     * @throws FtpException if an I/O or protocol error occurs
     */
    public void protectionLevel(char level)
            throws FtpException {
        sendRequest("PROT " + level);
        this.dataProtected = (level == DATA_PRIVATE);
    }

    /**
     * Setups socket timeout.
     *
     * @param s socket
     *
     * @throws FtpException if an error occurs
     */
    private void setSocketTimeout(Socket s)
            throws FtpException {
        try {
            s.setSoTimeout(source.getTimeout());
        } catch (Exception e) {
            throw new FtpException(e.getMessage(), e);
        }
    }

    /**
     * Sets the send buffer size option of the ftps connection.
     *
     * @param sendBufferSize Send buffer size.
     */
    public void setSendBufferSize(int sendBufferSize) {
        this.sendBufferSize = sendBufferSize;
    }

    /**
     * Sets the receive buffer size option of the ftps connection.
     *
     * @param receiveBufferSize Receive buffer size.
     */
    public void setReceiveBufferSize(int receiveBufferSize) {
        this.receiveBufferSize = receiveBufferSize;
    }

    public Socket mlsd(String dirname)
            throws FtpException {
        String command = "MLSD";
        command += (dirname.length() > 0) ? (" " + dirname) : "";
        return this.dataConnector.openConnection(command);
    }

    public FtpsCertificateVerifier getFtpsCertificateVerifier() {
        return certificateVerifier;
    }

    public void setFtpsCertificateVerifier(FtpsCertificateVerifier certificateVerifier) {
        this.certificateVerifier = certificateVerifier;
    }

    private void reuseCommandSslSession(SSLSocket dataSocket) {
//        if (!this.sslSessionResumptionRequired) {
//            return;
//        }

        try {
            SslContextFactory.reuseSession((SSLSocket) this.controlSocket, dataSocket);
        } catch (Exception e) {
            this.logger.info("Command channel SSL session reusing is not supported in current JRE. A new SSL session for data channel will be created.");
        }
    }

    /**
     * The FTP data connector. Opens the appropriate data connection
     * depending on the desired connection type (active or passive).
     *
     * @author Alex Usun
     * @version 1.0
     */
    private interface DataConnector {

        /**
         * Opens the appropriate data connection.
         *
         * @param command the command to send
         *
         * @return the opened data socket
         *
         * @throws FtpException if an I/O or protocol error occurs
         */
        Socket openConnection(String command)
                throws FtpException;

        /**
         * Closes the current connector.
         */
        void close();

    }   // DataConnector


    /**
     * The passive data connector. Used for outgoing (from the client side)
     * connections.
     *
     * @author Alex Usun
     * @version 1.0
     */
    private class PassiveConnector
            implements DataConnector {

        private ConnectionParameters params;
        private final boolean protect;
        private String serverHost;
        private FtpsClient client;
        private boolean connectBefore = false;

        private PassiveConnector(FtpsClient client,
                                String host,
                                String serverHost,
                                int port,
                                int timeout,
                                boolean tcpNoDelayEnabled,
                                boolean protect,
                                boolean connectBefore) {
            this.client = client;
            this.params = new ConnectionParameters(host, port, timeout);
            this.params.setTcpNoDelay(tcpNoDelayEnabled);
            this.protect = protect;
            this.serverHost = serverHost;
            this.connectBefore = connectBefore;
        }

        private PassiveConnector(FtpsClient client,
                                String host,
                                String serverHost,
                                int port,
                                int timeout,
                                boolean tcpNoDelayEnabled,
                                boolean protect,
                                boolean connectBefore,
                                String proxyHost,
                                int proxyPort,
                                String proxyUserId,
                                String proxyPassword,
                                String proxyType) {
            this.client = client;
            this.params = new ConnectionParameters(host,
                                                   port,
                                                   timeout,
                                                   proxyHost,
                                                   proxyPort,
                                                   proxyUserId,
                                                   proxyPassword,
                                                   proxyType);
            this.params.setTcpNoDelay(tcpNoDelayEnabled);
            
            this.protect = protect;
            this.serverHost = serverHost;
            this.connectBefore = connectBefore;
        }

        /**
         * Opens the appropriate data connection.
         *
         * @return the opened data socket
         *
         * @throws FtpException if an I/O or protocol error occurs
         */
        public Socket openConnection(String command)
                throws FtpException {
            try {
                if (!this.connectBefore) {
                    sendCommand(command);
                }

                Socket s = createSocket();

                setSocketTimeout(s);

                if (this.protect) {
                    SSLSocket sslSocket = (SSLSocket) protectSocket(s, true);
                    reuseCommandSslSession(sslSocket);
                    sslSocket.startHandshake(); // todo: why it is here?
                    s = sslSocket;
                }

                if (this.connectBefore) {
                    sendCommand(command);
                }

                return s;
            } catch (Exception e) {
                throw new FtpException(e.getMessage(), e);
            } finally {
                readResponse();
            }
        }

        private Socket createSocket()
                throws FtpException {
            try {
                this.params.setSendBuffer(sendBufferSize);
                this.params.setReceiveBuffer(receiveBufferSize);
                return this.params.createSocket();
            } catch (IOException e) {
                if (!params.getHost().equals(serverHost)) {
                    logger.fine("Unable to connect to host "
                                        + params.getHost()
                                        + ".  Substituting server address "
                                        + serverHost);
                    this.client.serverHost = serverHost;

                    try {
                        this.params = new ConnectionParameters(serverHost,
                                                               params.getPort(),
                                                               params.getConnectionTimeout());
                        this.params.setSendBuffer(sendBufferSize);
                        this.params.setReceiveBuffer(receiveBufferSize);

                        return this.params.createSocket();
                    } catch (Exception ez) {
                        throw new FtpException(ez.getMessage(), e);
                    }
                } else {
                    throw new FtpException("Unable to connect to host " + params.getHost(), e);
                }
            }
        }

        /**
         * Closes the current connector.
         */
        public void close() {
            // empty
        }

        /**
         * Sets the connect before indicator
         *
         * @param connect <code>true</code> the connection will be established
         *                before send the command; <code>false</code> otherwise
         */
        public void setConnectBeforeCommand(boolean connect) {
            this.connectBefore = connect;
        }

    }   // PassiveConnector

    /**
     * The active data connector. Used for the incoming data connection.
     *
     * @author Alex Usun
     * @version 1.0
     */
    private class ActiveConnector
            implements DataConnector {

        private final ServerSocket server;


        /**
         * Creates a new connector instance.
         *
         * @param range data port range
         * @param protect       if <code>true</code> the resulting connection
         *                      must be protected (SSL); <code>false</code> otherwise
         * @param ctx           the SSL context
         *
         * @throws FtpException if an I/O or protocol error occurs
         */
        private ActiveConnector(ServerSocketUtil.PortRange range, boolean protect, SSLContext ctx)
                throws FtpException {
            try {
                if (!protect) {
                    this.server = ServerSocketUtil.createServerSocket(range);
                } else {
                    this.server = ServerSocketUtil.createServerSocket(ctx.getServerSocketFactory(), range);
                    ((SSLServerSocket) this.server).setUseClientMode(true);
                }

                setServerTimeout();
            } catch (Exception e) {
                throw new FtpException(e.getMessage(), e);
            }
        }

        /**
         * Setups server timeout.
         *
         * @throws FtpException if an error occurs
         */
        private void setServerTimeout()
                throws FtpException {
            try {
                this.server.setSoTimeout(source.getTimeout());
            } catch (Exception e) {
                throw new FtpException(e.getMessage(), e);
            }
        }


        /**
         * Opens the appropriate data connection.
         *
         * @return the opened data socket
         *
         * @throws FtpException if an I/O or protocol error occurs
         */
        public Socket openConnection(String command)
                throws FtpException {
            sendCommand(command);
            try {
                Socket s = this.server.accept();
                s.setTcpNoDelay(connectionParameters.getTcpNoDelay());
                
                if (s instanceof SSLSocket) {
                    SSLSocket sslSocket = (SSLSocket) s;
                    reuseCommandSslSession(sslSocket);
                    sslSocket.startHandshake();
                }

                return s;
            } catch (Exception e) {
                throw new FtpException(e.getMessage(), e);
            } finally {
                readResponse();
            }
        }

        /**
         * Gets local port for server socket create
         *
         * @return the local port that server socket is listening on
         */
        public int getLocalPort() {
            return server.getLocalPort();
        }

        /**
         * Closes the current connector.
         */
        public void close() {
            try {
                this.server.close();
            } catch (Exception e) {
                // ignore
            }
        }
    }   // ActiveConnector


    /**
     * Class to hold the active mode hostname and full command.
     *
     * @author mjawwad
     */
    private class ActiveModeData {

        private String hostname = null;
        private String commandData = null;

        private ActiveModeData(String hostname, String commandData) {
            this.hostname = hostname;
            this.commandData = commandData;
        }

        public String getHostname() {
            return this.hostname;
        }

        public String getCommandData() {
            return this.commandData;
        }

        public boolean isEPRTRequired() {
            return this.getCommandData().startsWith("|");
        }
    }

    /**
     * Asynchronous reverse DNS lookup.
     *
     * @author mjawwad
     */
    private class AsyncReverseDNSLookup
            extends Thread {

        private String hostname;
        private boolean lookupComplete;

        private AsyncReverseDNSLookup(String hostname) {
            this.hostname = hostname;
        }

        public void run() {
            try {
                this.hostname = InetAddress.getByName(hostname).getHostAddress();
                this.lookupComplete = true;
            } catch (Exception e) {
                this.lookupComplete = false;
            }
        }
    }
}


