/*
 * $Id: FtpSimpleClient.java,v 0.0 2009/01/21 00:00:00 t-imamura Exp $
 *
 * Copyright (c) 2009 t-imamura, All rights reserved.
 */
package ftp;

import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.net.UnknownHostException;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * FTP簡易クライアント.
 * <br>
 * FTPの制御ソケットを作成し、直接コマンドを送信し操作を行います。
 * PORTモード・PASVモード両方のモードにも対応しています。
 * ファイルの送受信以外に、ディレクトリ操作なども行えます。
 *
 * <pre>
 * FtpSimpleClient ftp = new FtpSimpleClient("ftp.example.com", false);
 * // 接続
 * ftp.connect();
 * // ログイン
 * ftp.logon("user", "pass");
 *
 * // ファイル送信
 * byte[] data = 転送データ;
 * ftp.put("/home/user/ftptest.txt", data);
 *
 * // ファイル受信
 * OutputStream os = new OutputStream();
 * ftp.get("/home/user/ftptest.txt", baos);
 *
 * // ファイル削除
 * ftp.dele("/home/user/ftptest.txt");
 *
 * // 切断
 * ftp.disconnect();
 * </pre>
 */
public class FtpSimpleClient {

	/** FTP制御ポート番号 */
	private static final int FTP_PORT_NO = 21;
	/** アノニマスログインID */
	private static final String ANONYMOUS_USERID = "anonymous";
	/** PASVモード時の接続先を取得する為の正規表現 */
	private static final Pattern PASV_REPLY_MESSAGE = Pattern.compile(
		"227 .*\\(([0-9]{1,3}),([0-9]{1,3}),([0-9]{1,3}),([0-9]{1,3}),([0-9]{1,3}),([0-9]{1,3})\\).*");
	/** カレントディレクトリを取得する為の正規表現 */
	private static final Pattern PWD_REPLY_MESSAGE = Pattern.compile("257 .*\"(.*)\".*");

	/** IPアドレス */
	private final InetAddress address;
	/** ポート番号 */
	private final int portNo;

	/** FTP制御ソケット */
	private Socket ftpCtrl;
	/** FTP制御入力(OutputStream) */
	private BufferedWriter ctrlOut;
	/** FTP制御出力(InputStream) */
	private BufferedReader ctrlIn;

	/** パッシブモードか */
	private boolean passive = false;
	/** デバッグモード */
	private boolean debug = false;

	/**
	 * FTP簡易クライアントオブジェクトを構築します。
	 *
	 * @param address IPアドレス or ホスト名
	 * @param passive パッシブモードか
	 * @throws UnknownHostException <code>address</code>のIPアドレスが見つからなかった場合
	 */
	public FtpSimpleClient(String address, boolean passive) throws UnknownHostException {
		this(InetAddress.getByName(address), FTP_PORT_NO, passive);
	}

	/**
	 * FTP簡易クライアントオブジェクトを構築します。
	 *
	 * @param address IPアドレス
	 * @param passive パッシブモードか
	 */
	public FtpSimpleClient(InetAddress address, boolean passive) {
		this(address, FTP_PORT_NO, passive);
	}

	/**
	 * FTP簡易クライアントオブジェクトを構築します。
	 *
	 * @param address IPアドレス
	 * @param portNo  制御ポート番号
	 * @param passive パッシブモードか
	 */
	public FtpSimpleClient(InetAddress address, int portNo, boolean passive) {
		this.address = address;
		this.portNo = portNo;
		this.passive = passive;
	}

	@Override
	protected void finalize() throws Throwable {
		disconnect();
		super.finalize();
	}

	/**
	 * パッシブモードかを設定します。
	 *
	 * @param passive パッシブモードか
	 */
	public void setPassive(boolean passive) {
		this.passive = passive;
	}

	/**
	 * デバッグモードかを設定します。
	 *
	 * @param debug デバッグモードか
	 */
	public void setDebug(boolean debug) {
		this.debug = debug;
	}

	/**
	 * FTPコマンドを送信します。
	 * <br>
	 * 受信したリプライメッセージが必要な場合は、内容を入れる {@link StringBuffer} を
	 * <code>replyMessage</code> に指定します。
	 *
	 * @param command FTPコマンド
	 * @param replyMessage リプライメッセージ
	 * @return リプライコード
	 */
	private int sendCommand(String command, StringBuffer replyMessage) {
		int replyCode = 0;

		try {
			// コマンド送信
			if (command != null) {
				debug("FTP > %s", command);

				ctrlOut.write(command);
				ctrlOut.write("\r\n");
				ctrlOut.flush();
			}

			// レスポンス受信
			String reply = null;
			while ((reply = ctrlIn.readLine()) != null) {
				debug("FTP < %s", reply);
				if (replyMessage != null)
					replyMessage.append(reply);

				// リプライコード
				replyCode  = (reply.charAt(0) - '0') * 100;
				replyCode += (reply.charAt(1) - '0') * 10;
				replyCode += (reply.charAt(2) - '0') * 1;

				// 続かない場合は、終わり
				if (reply.charAt(3) == ' ') break;

				replyCode = 0;
				if (replyMessage != null)
					replyMessage.append("\n");
			}
		} catch (IOException e) {
			replyCode = 0;
		}

		return replyCode;
	}

	/**
	 * FTPコマンドを送信し、データコネクションを作成します。
	 *
	 * @param command FTPコマンド
	 * @return データコネクション
	 * @throws IOException 入出力エラーが発生した場合
	 */
	private Socket creatDataConnection(String command) throws IOException {
		return passive ? creatDataConnectionPasv(command) : creatDataConnectionPort(command);
	}

	/**
	 * FTPコマンドを送信し、PORTモードでデータコネクションを作成します。
	 *
	 * @param command FTPコマンド
	 * @return データコネクション
	 * @throws IOException 入出力エラーが発生した場合
	 */
	private Socket creatDataConnectionPort(String command) throws IOException {
		Socket ftpData = null;
		ServerSocket serverSocket = null;

		try {
			serverSocket = new ServerSocket(0, 1, ftpCtrl.getLocalAddress());

			byte[] ip = serverSocket.getInetAddress().getAddress();
			int port = serverSocket.getLocalPort();
			debug("INFO: Open data connection. (%d.%d.%d.%d:%d)",
					(ip[0] & 0xFF), (ip[1] & 0xFF), (ip[2] & 0xFF), (ip[3] & 0xFF), port);

			String cmdPort = "PORT "
					+ (ip[0] & 0xff) + "," + (ip[1] & 0xff) + ","
					+ (ip[2] & 0xff) + "," + (ip[3] & 0xff) + ","
					+ ((port / 256) & 0xff) + "," + (port & 0xff);

			if (sendCommand(cmdPort, null) == 200) {
				if (sendCommand(command, null) == 150) {
					ftpData = serverSocket.accept();
				}
			}

		} catch (IOException e) {
			// Bug: nullであることが明らかな参照ftpDataを無駄にnullチェックしています
			// if (ftpData != null) ftpData.close();
			// ftpData = null;
			throw e;
		} finally {
			if (serverSocket != null)
				serverSocket.close();
		}

		return ftpData;
	}

	/**
	 * FTPコマンドを送信し、PASVモードでデータコネクションを作成します。
	 *
	 * @param command FTPコマンド
	 * @return データコネクション
	 * @throws IOException 入出力エラーが発生した場合
	 */
	private Socket creatDataConnectionPasv(String command) throws IOException {
		Socket ftpData = null;

		try {
			StringBuffer msg = new StringBuffer();
			if (sendCommand("PASV", msg) != 227) {
				return null;
			}

			Matcher match = PASV_REPLY_MESSAGE.matcher(msg.toString());
			if (!match.find() || match.groupCount() != 6) {
				// データコネクションの情報が取れない。
				return null;
			}

			String address = match.group(1) + "." + match.group(2) + "."
					+ match.group(3) + "." + match.group(4);
			int portNo = Integer.parseInt(match.group(5)) * 256
					+ Integer.parseInt(match.group(6));
			debug("INFO: Connect data connection. (%s:%d)", address, portNo);

			ftpData = new Socket(address, portNo);

			if (sendCommand(command, null) != 150) {
				ftpData.close();
				ftpData = null;
			}
		} catch (NumberFormatException e) {
			ftpData = null;
		} catch (IOException e) {
			if (ftpData != null)
				ftpData.close();
			ftpData = null;
			throw e;
		}

		return ftpData;
	}

	/**
	 * 接続します。
	 *
	 * @return 成功したか
	 */
	public boolean connect() {
		if (ftpCtrl != null) return false;

		try {
			ftpCtrl = new Socket(address, portNo);
			ctrlOut = new BufferedWriter(new OutputStreamWriter(ftpCtrl.getOutputStream()));
			ctrlIn  = new BufferedReader(new InputStreamReader(ftpCtrl.getInputStream()));

			if (sendCommand(null, null) != 220) {
				disconnect();
				return false;
			}
		} catch (IOException e) {
			try {
				if (ctrlIn != null)
					ctrlIn.close();
				if (ctrlOut != null)
					ctrlOut.close();
				if (ftpCtrl != null)
					ftpCtrl.close();
			} catch (IOException ex) {
				// クローズの例外は無視
			}
			ftpCtrl = null;
			ctrlOut = null;
			ctrlIn  = null;
			return false;
		}
		return true;
	}

	/**
	 * 切断します。
	 *
	 * @return 成功したか
	 */
	public boolean disconnect() {
		if (ftpCtrl == null) return false;
		boolean result = true;

		// 終了
		if (sendCommand("QUIT", null) != 221) {
			result = false;
		}

		try {
			if (ctrlIn != null)
				ctrlIn.close();
		} catch (IOException e) {
			result = false;
		} finally {
			ctrlIn = null;
		}
		try {
			if (ctrlOut != null)
				ctrlOut.close();
		} catch (IOException e) {
			result = false;
		} finally {
			ctrlOut = null;
		}
		try {
			if (ftpCtrl != null)
				ftpCtrl.close();
		} catch (IOException e) {
			result = false;
		} finally {
			ftpCtrl = null;
		}

		return result;
	}

	/**
	 * アノニマスログインします。
	 *
	 * @return 成功したか
	 */
	public boolean anonymousLogon() {
		return logon(ANONYMOUS_USERID, ANONYMOUS_USERID);
	}

	/**
	 * アノニマスログインします。
	 *
	 * @param email メールアドレス
	 * @return 成功したか
	 */
	public boolean anonymousLogon(String email) {
		return logon(ANONYMOUS_USERID, email);
	}

	/**
	 * ログインします。
	 *
	 * @param user ユーザ名
	 * @param pass パスワード
	 * @return 成功したか
	 */
	public boolean logon(String user, String pass) {
		if (user == null) return false;
		if (pass == null) return false;
		if (ftpCtrl == null) connect();
		boolean result = true;

		// ユーザ
		if (sendCommand("USER " + user, null) != 331) {
			result = false;
		}

		// パスワード
		if (sendCommand("PASS " + pass, null) != 230) {
			result = false;
		}

		return result;
	}

	/**
	 * 転送データの形式を設定します。
	 *
	 * @param binary バイナリデータか
	 * @return 成功したか
	 */
	public boolean type(boolean binary) {
		if (ftpCtrl == null) return false;
		boolean result = true;

		if (sendCommand(binary ? "TYPE I" : "TYPE A", null) != 250) {
			result = false;
		}

		return result;
	}

	/**
	 * データを送信します。
	 *
	 * @param name ファイル名
	 * @param data データ
	 * @return 成功したか
	 */
	public boolean put(String name, byte[] data) {
		if (ftpCtrl == null) return false;
		if (name == null || name.isEmpty()) return false;
		if (data == null) return false;
		boolean result = true;

		Socket ftpData = null;
		OutputStream dataOut = null;
		InputStream dataIn = null;

		try {
			ftpData = creatDataConnection("STOR " + name);
			dataOut = ftpData.getOutputStream();
			dataIn  = ftpData.getInputStream();

			// データ送信
			dataOut.write(data);
			dataOut.close();
			dataOut = null;
			if (sendCommand(null, null) != 226) {
				result = false;
			}

		} catch (IOException e) {
			result = false;
		} finally {
			try {
				if (dataIn != null)
					dataIn.close();
			} catch (IOException e) {
				result = false;
			} finally {
				dataIn = null;
			}
			try {
				if (dataOut != null)
					dataOut.close();
			} catch (IOException e) {
				result = false;
			} finally {
				dataOut = null;
			}
			try {
				if (ftpData != null)
					ftpData.close();
			} catch (IOException e) {
				result = false;
			} finally {
				ftpData = null;
			}
		}

		return result;
	}

	/**
	 * データを受信します。
	 *
	 * @param name ファイル名
	 * @param data データ
	 * @return 成功したか
	 */
	public boolean get(String name, OutputStream data) {
		if (ftpCtrl == null) return false;
		if (name == null || name.isEmpty()) return false;
		if (data == null) return false;
		boolean result = true;

		Socket ftpData = null;
		OutputStream dataOut = null;
		InputStream dataIn = null;

		try {
			ftpData = creatDataConnection("RETR " + name);
			dataOut = ftpData.getOutputStream();
			dataIn = ftpData.getInputStream();

			byte[] buf = new byte[1024];
			int len = 0;

			// データ受信
			while ((len = dataIn.read(buf)) != -1) {
				data.write(buf, 0, len);
			}
			dataIn.close();
			dataIn = null;
			if (sendCommand(null, null) != 226) {
				result = false;
			}

		} catch (IOException e) {
			result = false;
		} finally {
			try {
				if (dataIn != null)
					dataIn.close();
			} catch (IOException e) {
				result = false;
			} finally {
				dataIn = null;
			}
			try {
				if (dataOut != null)
					dataOut.close();
			} catch (IOException e) {
				result = false;
			} finally {
				dataOut = null;
			}
			try {
				if (ftpData != null)
					ftpData.close();
			} catch (IOException e) {
				result = false;
			} finally {
				ftpData = null;
			}
		}

		return result;
	}

	/**
	 * ファイルを削除します。
	 *
	 * @param name ファイル名
	 * @return 成功したか
	 */
	public boolean dele(String name) {
		if (ftpCtrl == null) return false;
		if (name == null || name.isEmpty()) return false;
		boolean result = true;

		if (sendCommand("DELE " + name, null) != 250) {
			result = false;
		}

		return result;
	}

	/**
	 * 1つ上位のディレクトリにカレントディレクトリを移動します。
	 *
	 * @return 成功したか
	 */
	public boolean cdup() {
		if (ftpCtrl == null) return false;
		boolean result = true;

		if (sendCommand("CDUP", null) != 250) {
			result = false;
		}

		return result;
	}

	/**
	 * カレントディレクトリを移動します。
	 * ディレクトリパスは、絶対パスもしくは相対パスで指定します。
	 *
	 * @param path ディレクトリパス
	 * @return 成功したか
	 */
	public boolean cwd(String path) {
		if (ftpCtrl == null) return false;
		if (path == null || path.isEmpty()) return false;
		boolean result = true;

		if (sendCommand("CWD " + path, null) != 250) {
			result = false;
		}

		return result;
	}

	/**
	 * 指定したディレクトリを削除します。
	 * ディレクトリパスは、絶対パスもしくは相対パスで指定します。
	 *
	 * @param path ディレクトリパス
	 * @return 成功したか
	 */
	public boolean rmd(String path) {
		if (ftpCtrl == null) return false;
		if (path == null || path.isEmpty()) return false;
		boolean result = true;

		if (sendCommand("RMD " + path, null) != 250) {
			result = false;
		}

		return result;
	}

	/**
	 * 指定したディレクトリを作成します。
	 * ディレクトリパスは、絶対パスもしくは相対パスで指定します。
	 *
	 * @param path ディレクトリパス
	 * @return 成功したか
	 */
	public boolean mkd(String path) {
		if (ftpCtrl == null) return false;
		if (path == null || path.isEmpty()) return false;
		boolean result = true;

		if (sendCommand("MKD " + path, null) != 250) {
			result = false;
		}

		return result;
	}

	/**
	 * カレントディレクトリのファイル一覧を取得します。
	 *
	 * @return ファイル一覧
	 */
	public String list() {
		return list(null);
	}

	/**
	 * ファイル一覧を取得します。
	 *
	 * @param path ディレクトリパス
	 * @return ファイル一覧
	 */
	public String list(String path) {
		if (ftpCtrl == null) return null;

		Socket ftpData = null;
		OutputStream dataOut = null;
		InputStream dataIn = null;
		ByteArrayOutputStream result = new ByteArrayOutputStream();

		String command = "LIST";
		if(path != null) command += " " + path;

		try {
			ftpData = creatDataConnection(command);
			dataOut = ftpData.getOutputStream();
			dataIn = ftpData.getInputStream();

			byte[] buf = new byte[1024];
			int len = 0;

			// データ受信
			while ((len = dataIn.read(buf)) != -1) {
				result.write(buf, 0, len);
			}
			result.flush();
			dataIn.close();
			dataIn = null;
			if (sendCommand(null, null) != 226) {
				result.reset();
			}

		} catch (IOException e) {
			result.reset();
		} finally {
			try {
				if (dataIn != null)
					dataIn.close();
			} catch (IOException e) {
				result.reset();
			} finally {
				dataIn = null;
			}
			try {
				if (dataOut != null)
					dataOut.close();
			} catch (IOException e) {
				result.reset();
			} finally {
				dataOut = null;
			}
			try {
				if (ftpData != null)
					ftpData.close();
			} catch (IOException e) {
				result.reset();
			} finally {
				ftpData = null;
			}
		}

		return result.toString();
	}

	/**
	 * カレントディレクトリを取得します。
	 *
	 * @return ディレクトリパス
	 */
	public String pwd() {
		if (ftpCtrl == null) return null;

		StringBuffer reply = new StringBuffer();
		if (sendCommand("PWD", reply) != 257) {
			return null;
		}

		Matcher match = PWD_REPLY_MESSAGE.matcher(reply.toString());
		if (!match.find() || match.groupCount() != 1) {
			return null;
		}

		return match.group(1);
	}

	/**
	 * 指定された書式の文字列と引数を使って、書式付き文字列をコンソールに出力します。
	 * <code>debug == false</code> の場合は、出力されません。
	 *
	 * @param format 書式文字列
	 * @param args 書式文字列内の書式指示子により参照される引数
	 */
	private void debug(String format, Object... args) {
		if (!debug) return;
		System.out.println(String.format(format, args));
	}

}
