// FTPClient.cs
//
//  Copyright (C) 2008-2009 Christian Eide
//
// This program is free software; you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation; either version 2 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
// 
// You should have received a copy of the GNU General Public License
// along with this program; if not, write to the Free Software
// Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
//

using System;
using System.Net;
using System.Net.Sockets;
using System.Net.Security;
using System.IO;
using System.Collections.Generic;
using System.Text.RegularExpressions;
using System.Security.Cryptography.X509Certificates;
using bareFTP.Preferences;

namespace bareFTP.Protocol.Ftp
{	
	public class FTPClient
	{
        internal const string EOL = "\r\n";
		private static int DATA_PORT_RANGE_FROM = 1500;
		private static int DATA_PORT_RANGE_TO = 65000;
		private static int BLOCK_SIZE = 512;
		private List<string> features;
		private Config conf;
		private bool read;
		private bool abort = false;
		
		// default encoding
		//private System.Text.Encoding readerenc = System.Text.Encoding.ASCII;
		
		TcpClient tcpClient;
		StreamReader reader = null;
		StreamWriter writer = null;
		string charset = string.Empty;
		FTPMode ftpMode;
		bool SSL;
		string system;
		string prot_level = string.Empty;
		
		public string RemoteSystem
		{
			set { system = value; }
		}

		public List<string> Features
		{
			get { return features; }
		}
		
		public string RemoteCharset
		{
			set { charset = value; }
		}
		
		public FTPClient(FTPMode mode, bool SSL, Config conf)
		{
			this.conf = conf;
			tcpClient = new TcpClient();
			tcpClient.SendTimeout = conf.NetworkTimeout;
			tcpClient.ReceiveTimeout = conf.NetworkTimeout;
			
			ftpMode = mode;
			this.SSL = SSL;
		}
		
		public void Connect(string remoteHost, int remotePort, string user, string password)
		{
			tcpClient.Connect(bareFTP.Protocol.HostResolver.GetAddress(remoteHost), remotePort);
			Stream stream = tcpClient.GetStream();
			
            writer = new StreamWriter(stream, System.Text.Encoding.ASCII);
			reader = new StreamReader(stream, System.Text.Encoding.ASCII);
			
			CheckReply(GetReply(), 220);
			
			try
			{
				reader = new StreamReader(stream, GetEncoding());
				writer = new StreamWriter(stream, System.Text.Encoding.ASCII);
			}
			catch(Exception ex)
			{
				this.Close();
				throw ex;
			}
			
			// Check if we need to use SSL
			if(SSL)
			{
				string plevel;
				if(!string.IsNullOrEmpty(ProtLevel))
					plevel = ProtLevel;
				else
					plevel = conf.FTPSDataChannelProtectionLevel;
				   
				CheckReply(SendCommand("AUTH TLS"), 234);
				
				SslStream sslstream = new SslStream(tcpClient.GetStream(), false, CertificateValidation);
				
				try
				{
					sslstream.AuthenticateAsClient(remoteHost);
				}
				catch(Exception ex)
				{
					reader.Close();
					reader.Dispose();
					reader = null;
					writer.Close();
					writer.Dispose();
					writer = null;
					tcpClient.Close();
					sslstream.Close();
					sslstream.Dispose();
					sslstream = null;
					
					//Console.WriteLine(ex.ToString());
					
					throw new Exception(string.Format("Certificate Error ({0})", ex.Message));
				}
				
				writer = new StreamWriter(sslstream, GetEncoding());
				reader = new StreamReader(sslstream, GetEncoding());
				
				CheckReply(SendCommand("PBSZ 0"), 200);
				CheckReply(SendCommand(string.Format("PROT {0}", plevel)), true, 200,534);
			}
			
			try
			{ 
				CheckReply(SendCommand(string.Format("USER {0}",user)), 331);
				CheckReply(SendCommand(string.Format("PASS {0}", password)), 230);
			}
			catch(Exception e)
			{ 
				stream.Close();
				stream.Dispose();
				stream = null;
				writer = null;
				reader = null;
				tcpClient.Close();
				tcpClient = null;
				throw e;
			}

			GetSiteFeatures();
			SetTransferType(Ftp.FTPFileTransferType.Binary);
		}
		
		public bool CertificateValidation (object sender, X509Certificate certificate, X509Chain chain, SslPolicyErrors sslPolicyErrors)
        {
			if(sslPolicyErrors == SslPolicyErrors.None)
			{
				return true;
			}
            else
			{
				// TODO: Check out how to inspect the chain better...
				if(conf.FTPSVerifyServerSertificate)
				{
					OnLogTextEmitted(new LogTextEmittedArgs(-2, String.Format("Server certificate error: {0}", sslPolicyErrors)));
					OnLogTextEmitted(new LogTextEmittedArgs(-2, "Try disabling Server Certificate Validation"));
					return false;
				}					
				else
					return true;
			}
        }
		
		public void Close()
		{
			if(tcpClient.Connected)
			{
				try
				{
					CheckReply(SendCommand("QUIT"), 221);
				}
				catch
				{
					tcpClient.Close();	
				}
			}
			if(tcpClient.Connected)
				tcpClient.Close();
		}
		
		public bool Connected
		{
			get {
				if(tcpClient != null)
					return tcpClient.Connected;
				else
					return false;
			}
		}

		public string ProtLevel {
			get {
				return prot_level;
			}
			set {
				prot_level = value;
			}
		}
		
		public string GetCurrentDirectory()
		{
			List<FTPReply> replies = CheckReply(SendCommand("PWD"), 257);
			return replies[0].Message;
		}
		
		public List<FTPReply> SendCommand(string command)
		{
				
			Byte[] cmdBytes = GetEncoding().GetBytes((command+EOL).ToCharArray());
			OnLogTextEmitted(new LogTextEmittedArgs(-1, command));
			try
			{
				if(writer.BaseStream.CanWrite)
				{
					writer.BaseStream.Write(cmdBytes, 0, cmdBytes.Length);
					writer.Flush();
				}
				else
					throw new Exception("Can't write to network stream");
			}
			catch(Exception ex)
			{
				OnLogTextEmitted(new LogTextEmittedArgs(MessageType.Error, ex.Message));
				return null;
			}
			return GetReply();
		}
		
		public void SendCustomCommand(string command)
		{
			List<string> lines = new List<string>();
			DataSocket dsocket = CreateDataSocket();
			SetTransferType(FTPFileTransferType.ASCII);
			List<FTPReply> replies = SendCommand(command);
			CheckReply(replies, -1);
			foreach(FTPReply r in replies)
			{
				if(r.ReplyCode == 125	 || r.ReplyCode == 150)
				{
					lines = ReadLines(dsocket.GetStream());
					dsocket.Close();
					foreach(string s in lines)
					OnLogTextEmitted(new LogTextEmittedArgs(MessageType.Info, s));
					// Read reply from control socket. Should be 226
					CheckReply(GetReply(), -1);
					return;
				}
			}
			dsocket.Close();
		}
		
		public void ChangeDir(string dir)
		{
			CheckReply(SendCommand(string.Format("CWD {0}", dir)), 250);
		}
		
		public bool IsDir(string cwd, string dir)
		{
			bool isdir = false;
			foreach(FTPReply reply in SendCommand(string.Format("CWD {0}", dir)))
			{
				if(reply.ReplyCode == 250)
					isdir = true;
			}
			SendCommand(string.Format("CWD {0}", cwd));
			return isdir;
		}
		
		public void MakeDir(string directoryName)
		{
			CheckReply(SendCommand(string.Format("MKD {0}", directoryName)), 257);
		}
		
		public List<string> XDir()
		{
			List<string> lines = new List<string>();
			
			SetTransferType(FTPFileTransferType.ASCII);
			
			if(ftpMode == FTPMode.Passive)
			{
				if(features.Contains("PRET") || features.Contains("pret"))
					CheckReply(SendCommand("PRET LIST"), true, 200);
			}
			
			DataSocket dsocket = CreateDataSocket();
			
			// Send the list command. Server should reply with 150
			string list_command = "LIST -La";
			//if(features.Contains("MLSD"))
			//	list_command = "MLSD";
			
			if(system.IndexOf("VMS") >= 0 || system.Contains("MultiNet Unix Emulation"))
				list_command = "LIST";
			
			List<FTPReply> replies = SendCommand(list_command);
			
			CheckReply(replies, 150, 125);
			
			lines = ReadLines(dsocket.GetStream());
			
			// Uncomment the following to dump directory listing in console
			//foreach(string l in lines)
			//	Console.WriteLine(l);
			
			dsocket.Close();			
			// Read reply from control socket. Should be 226
			CheckReply(GetReply(), 226);
			
			return lines;
		}
		
		public List<string> Dir()
		{
			List<string> lines = new List<string>();
			
			SetTransferType(FTPFileTransferType.ASCII);
			if(ftpMode == FTPMode.Passive)
			{
				if(features.Contains("PRET") || features.Contains("pret"))
					CheckReply(SendCommand("PRET NLST"), true, 200);
			}
			
			DataSocket dsocket = CreateDataSocket();
			CheckReply(SendCommand("NLST"), 150, 125, 550);
			lines = ReadLines(dsocket.GetStream());
			dsocket.Close();
			// Read reply from control socket. Should be 226
			CheckReply(GetReply(), 226);
		
			return lines;
		}
		
		public void RetrieveFile(XferFile file, FileAction action, System.IO.Stream fstream)
		{
			if(ftpMode == FTPMode.Passive)
			{
				if(features.Contains("PRET") || features.Contains("pret"))
				{
						CheckReply(SendCommand(string.Format("PRET RETR {0}", file.Path.FileNameRemoteAbs)), true, 200);
				}	
			}
			DataSocket dsocket = CreateDataSocket();
			
			SetTransferType(FTPFileTransferType.Binary);
			
			if(action == FileAction.Resume)
			{
				file.TransferedBytes = fstream.Length;
				CheckReply(SendCommand(string.Format("REST {0}", fstream.Length.ToString())), 350);
			}
							
			CheckReply(SendCommand(string.Format("RETR {0}", file.Path.FileNameRemoteAbs)), 125, 150);
			
			file.Status = bareFTP.Protocol.DownloadStatus.Downloading;
			
			Stream networkStream = dsocket.GetStream();
			Byte[] buffer = new Byte[BLOCK_SIZE];
			int bytes = 0;

			read = true;
			
			while(read)
			{
				bytes = (int)networkStream.Read(buffer, 0, buffer.Length);
				fstream.Write(buffer, 0, bytes);
				file.TransferedBytes += (long)bytes;

				if(bytes == 0)
					read = false;
			}
			
			if(abort)
			{
				networkStream.Close();
				dsocket.Close();
				file.Status = bareFTP.Protocol.DownloadStatus.Aborted;
				abort = false;
				throw new TransferAbortedException();
			}
			else
			{
				file.Status = bareFTP.Protocol.DownloadStatus.Finished;	
				networkStream.Close();
				dsocket.Close();
				CheckReply(GetReply(), 226);
			}
		}
		
		public void StoreFile(XferFile file, FileAction action, System.IO.Stream fstream)
		{
			
			SetTransferType(FTPFileTransferType.Binary);
			
			if(ftpMode == FTPMode.Passive)
			{
				if(features.Contains("PRET") || features.Contains("pret"))
				{
					if(action == FileAction.Resume || action == FileAction.Append)
						CheckReply(SendCommand(string.Format("PRET APPE {0}", file.Path.FileNameRemoteAbs)), true, 200);
					else
						CheckReply(SendCommand(string.Format("PRET STOR {0}", file.Path.FileNameRemoteAbs)), true, 200);
				}
			}
			
			DataSocket dsocket = CreateDataSocket();
			long fslength = fstream.Length;
			if(action == FileAction.Resume)
			{
				CheckReply(SendCommand(string.Format("APPE {0}", file.Path.FileNameRemoteAbs)), 125, 150);
				fstream.Position = file.Marker;
				fslength = fslength - file.Marker;
				file.TransferedBytes = file.Marker;
			}
			else if(action == FileAction.Append)
			{
				CheckReply(SendCommand(string.Format("APPE {0}", file.Path.FileNameRemoteAbs)), 125, 150);
			}
			else
				CheckReply(SendCommand(string.Format("STOR {0}", file.Path.FileNameRemoteAbs)), 125, 150);
			
			Stream networkStream = dsocket.GetStream();

			Byte[] buffer = new Byte[BLOCK_SIZE];
			int bytes = 0;
			
			file.Status = bareFTP.Protocol.DownloadStatus.Uploading;
		
			while(file.TransferedBytes < fslength && file.Status != DownloadStatus.Aborted && !abort)
			{
				bytes = (int)fstream.Read(buffer, 0, BLOCK_SIZE);
				networkStream.Write(buffer, 0, bytes);
				file.TransferedBytes += (long)bytes;
			}
			
			networkStream.Close();
			dsocket.Close();
			
			if(abort)
			{
				file.Status = DownloadStatus.Aborted;
				abort = false;
				try
				{
					GetReply();
				}
				catch(NullReferenceException)
				{
					throw new TransferAbortedException();	
				}
			}
			else
			{
				file.Status = DownloadStatus.Finished;
				CheckReply(GetReply(), 226);
			}
		}
		
		public List<FTPReply> GetReply()
		{
			
			try
			{
				List<FTPReply> replies = new List<FTPReply>();
				
				// read first line
				//if(reader == null)
				//	return replies;
				string line = string.Empty;
				
				try
				{
					line = reader.ReadLine();
				}
				catch(IOException)
				{
					System.Threading.Thread.Sleep(200);
					try
					{
						line = reader.ReadLine();
					}
					catch(IOException ex)
					{
						OnLogTextEmitted(new LogTextEmittedArgs(MessageType.Error, "Network error: " + ex.Message));
						return null;
					}
				}
				
				while (line != null && line.Length == 0)
				{					
					System.Threading.Thread.Sleep(100);
					line = reader.ReadLine();
				}
				if (line == null)
					throw new SystemException("Unexpected null reply received");
            
				if (line.Length < 3)
					throw new SystemException("Short reply received");
            
				string replyCode = line.Substring(0, 3);
			
				string reply = "";
			
				if (line.Length > 4)
					reply = line.Substring(4);
				
				// check for multiline response and build up
				// the reply
				if (line[3] == '-')
				{
					bool complete = false;
					//reply += Environment.NewLine;
					while (!complete && line != null)
					{
						line = reader.ReadLine();
						if (line == null)
							throw new SystemException("Unexpected null reply received");
					
						if (line.Length == 0)
							continue;
                    
						if(line.Length > 3)
						{
							if (line.Substring(0,3) == replyCode && line[3] == ' ')
							{
								complete = true;
								reply += "\r\n" + line.Substring(4);
								//replies.Add(new FTPReply(Convert.ToInt32(tmpreplyCode), tmpreply));
							}
							else
								reply += "\r\n" + line;
						}
						else
							reply += "\r\n" + line;// + Environment.NewLine;
					}
				}
				
				replies.Add(new FTPReply(Convert.ToInt32(replyCode), reply));
				return replies;
			
			}
			catch(Exception ex)
			{
				throw ex;
			}
        }
		
		public void Delete(string filename)
		{
			CheckReply(SendCommand(string.Format("DELE {0}", filename)), 250);	
		}
		
		public void Abort()
		{
			read = false;
			abort = true;
		}
		
		public void RemoveDir(string dirname)
		{
			CheckReply(SendCommand(string.Format("RMD {0}", dirname)), 250);
		}

		public void Chmod(string mode, string filename)
		{
			Chmod(mode, filename, false);
		}
		
		public void Chmod(string mode, string filename, bool silent)
		{
			CheckReply(SendCommand(string.Format("SITE CHMOD {0} {1}", mode, filename)), silent, 200);
		}
		
		public void RenameFile(string fromRemoteFileName, string toRemoteFileName)
		{
		
			CheckReply(SendCommand(string.Format("RNFR {0}", fromRemoteFileName)), 350);
			CheckReply(SendCommand(string.Format("RNTO {0}", toRemoteFileName)), 250);
		}
		
		internal List<string> ReadLines(Stream stream)
		{
			
			List<string> lines = new List<string>();
			
			if(stream == null)
				throw new Exception("Input stream is null");
			
			using(StreamReader input = new StreamReader(stream, GetEncoding()))
			{
				
				string line = null;
				
				while ((line = ReadLine(input)) != null)
					lines.Add(line);
            
				input.Close();
			}
			return lines;
		}
		
		internal virtual string ReadLine(TextReader input)
        {
			return input.ReadLine();
		}
			
		internal DataSocket CreateDataSocket()
        {            
			
			string hostIP = string.Empty;
			int port = 0;
			
			if(ftpMode == FTPMode.Passive)
			{
				List<FTPReply> replies = CheckReply(SendCommand("PASV"), 227);
				
				FTPReply replyObj = null;
				foreach(FTPReply repObj in replies)
				{
					if(repObj.ReplyCode == 227)
						replyObj = repObj;
				}
				// If we have null here then throw exeption
				if(replyObj == null)
					throw new Exception("Did not receive proper response to PASV command");
				
	            string reply = replyObj.Message;
	            
	            Regex regEx = new Regex(@"(?<a0>\d{1,3}),(?<a1>\d{1,3}),(?<a2>\d{1,3}),(?<a3>\d{1,3}),(?<p0>\d{1,3}),(?<p1>\d{1,3})");
	            Match m1 = regEx.Match(reply);

	            string ipAddress = m1.Groups["a0"].Value + "." + m1.Groups["a1"].Value + "." + m1.Groups["a2"].Value + "." + m1.Groups["a3"].Value;
	            
	            int[] portParts = new int[2];
	            portParts[0] = Int32.Parse(m1.Groups["p0"].Value);
	            portParts[1] = Int32.Parse(m1.Groups["p1"].Value);
	            port = (portParts[0] << 8) + portParts[1];
	                     
				hostIP = ipAddress;

			}
			else if(ftpMode == FTPMode.Active)
			{
				Random rnd = new Random((int)DateTime.Now.Ticks);
				port = DATA_PORT_RANGE_FROM + rnd.Next(DATA_PORT_RANGE_TO - DATA_PORT_RANGE_FROM);
				
				int portHigh = port >> 8;
				int portLow = port & 255;
				
				string command = string.Format("PORT {0}, {1}, {2}", 
				                               GetLocalAddressList()[0].ToString().Replace(".", ","),
				                               portHigh.ToString(),
				                               portLow.ToString());
				
				CheckReply(SendCommand(command), 200);
			}
			
			string plevel;
				if(!string.IsNullOrEmpty(ProtLevel))
					plevel = ProtLevel;
				else
					plevel = conf.FTPSDataChannelProtectionLevel;
			
			return new DataSocket(hostIP, port, ftpMode, SSL, plevel, conf);
        }
		
		internal void SetTransferType(FTPFileTransferType type)
		{
			switch (type)
			{
				case FTPFileTransferType.ASCII:
					SetMode("TYPE A");
					break;
				case FTPFileTransferType.Binary:
					SetMode("TYPE I");
					break;
				default:
					throw new Exception("Invalid File Transfer Type");
			}
		}
			   
		internal void SetMode(string mode)
		{
			CheckReply(SendCommand(mode), 200);
		}
		
		private IPAddress[] GetLocalAddressList()
		{
			return Dns.GetHostEntry(Dns.GetHostName()).AddressList;
		}
		
		private void GetSiteFeatures()
		{
			features = new List<string>();
			try
			{
			
				foreach(FTPReply reply in CheckReply(SendCommand("FEAT"), true, 211))
				{
					if(reply.ReplyCode == 211)
					{
						using(StringReader sr = new StringReader(reply.Message))
						{
							string line = sr.ReadLine();
							while(line != null)
							{
								if(line.IndexOf("Features") < 0 && line.IndexOf("End") < 0)
									features.Add(line.Trim());
								line = sr.ReadLine();
							}
						}
					}
					
				}
			}
			catch(Exception)
			{
				features = new List<string>();
			}
		}
		
		public List<FTPReply> CheckReply(List<FTPReply> reply, params int[] codes)
		{
			return CheckReply(reply, false, codes);
		}
		
		public List<FTPReply> CheckReply(List<FTPReply> reply, bool silent, params int[] codes)
		{
			if(reply == null)
				return null;
			
			List<int> codelist = new List<int>(codes);
			
			if(silent)
			{
				foreach(FTPReply r in reply)
					r.Silent = silent;
			}
			
			OnLogTextEmitted(new LogTextEmittedArgs(reply));
			
			string msgstr = string.Empty;
			foreach(FTPReply r in reply)
			{
				msgstr += r.Message + System.Environment.NewLine;
				if(codelist.Contains(r.ReplyCode) || codelist.Contains(-1))
					return reply;
			}
			
			foreach(FTPReply r in reply)
			{
				if(r.ReplyCode == 421)
				{
					throw new TimeoutException();
				}
			}
			
			if(!silent)
				throw new FtpException(msgstr);
			else
				return reply;
		}
		
		private System.Text.Encoding GetEncoding()
		{
			if(!string.IsNullOrEmpty(charset))
				return System.Text.Encoding.GetEncoding(charset);
			if(!string.IsNullOrEmpty(conf.General_RemoteCharset))
				return System.Text.Encoding.GetEncoding(conf.General_RemoteCharset);
			if(features != null && features.Contains("UTF8"))
				return System.Text.Encoding.UTF8;
			
			return System.Text.Encoding.Default;
		}
		
		public event EventHandler LogTextEmitted;
		public virtual void OnLogTextEmitted(LogTextEmittedArgs e)
		{
			LogTextEmitted(this, e);
		}
		
	}
}
