package customsocket;

import java.io.IOException;
import java.io.InputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.net.SocketAddress;
import java.net.UnknownHostException;
import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.Timer;
import java.util.TimerTask;

import anywheresoftware.b4a.BA;
import anywheresoftware.b4a.keywords.Bit;
import anywheresoftware.b4a.keywords.DateTime;
import anywheresoftware.b4a.objects.collections.Map;
import anywheresoftware.b4a.objects.collections.Map.MyMap;
import anywheresoftware.b4a.randomaccessfile.B4XSerializator;
import jGPIO.mycodes;

@BA.ShortName("TCPSocket")
@BA.Events(values= {
		"log(msg as string)",
		"connected(targetip as string, targetport as int)",
		"disconnected(targetip as string, targetport as int, duration as long)",
		"newmapdata(value as Map)",
		"newbytesdata(value() as byte)",
		"newstringdata(value as String)",
		"newintsdata(value() as int)",
		
})

//@BA.DependsOn(values= {"jRandomAccessFile.jar"})

public class TCPSocket {
	private BA bax;
	private Object caller;
	private Object Me = this;
	private String event;
	private TcpSocketJavaEvent _javaevent = null;
	
	private boolean _inited = false;
	private boolean need_log_event = false;
	private boolean need_connected_event = false;
	private boolean need_disconnected_event = false;
	private boolean need_newmapdata_event = false;
	private boolean need_newbytesdata_event = false;
	private boolean need_newstringdata_event = false;
	private boolean need_newintsdata_event = false;
	
	
	private Socket _socket;
	private ObjectOutputStream _output;
	private ObjectInputStream _input;
	private B4XSerializator _converter;
	
	private String _remoteip = "";
	private int _remoteport = 0;
	private String _localip = "";
	private int _localport = 0;
	
	private long _connected_tick = 0;
	
	private int _txokcount = 0;
	private long _txbytescount = 0;
	private int _rxokcount = 0;
	private long _rxbytescount = 0;
	
	private Timer onesecondtimer;
	private int _rxtimercount;
	private int _txtimercount;
	
	private String _mapkey = "";
	
	public TCPSocket() {
		// nothing to do here
		_converter = new B4XSerializator();
		
	}
	
	@BA.Hide
	public TCPSocket(Socket newsocket, Object event) {
		this();
		_javaevent = (TcpSocketJavaEvent) event;
		Thread tx = new Thread(new tcpsocketrun(newsocket));
		tx.start();
	}
	
	/**
	 * Get / Set Identifier Key
	 * by default, identifier key is RemoteIPAddress:RemotePort
	 * if identifier key is empty, then TCPSocket has never been connected yet
	 * User may change IdentifierKey to any String value , and used it for matching Map
	 * @return key as string
	 */
	public String getIdentifierKey() {
		return _mapkey;
	}
	
	/**
	 * Get / Set Identifier Key
	 * by default, identifier key is RemoteIPAddress:RemotePort
	 * if identifier key is empty, then TCPSocket has never been connected yet
	 * User may change IdentifierKey to any String value , and used it for matching Map
	 * @param value as string
	 */
	public void setIdentifierKey(String value) {
		if (value instanceof String) {
			if (!value.isEmpty()) {
				if (_javaevent instanceof TcpSocketJavaEvent) {
					_javaevent.renameidentifier(_mapkey, value);
				}
				_mapkey = value;
			}
		}
	}
	
	/**
	 * Initialize TCPSocket events
	 * Necessary to call this, so event log, connected, disconnected will be called
	 * @param callerobject : caller object
	 * @param eventname : eventname
	 */
	public void InitializeEvents(BA ba, final Object callerobject, final String eventname) {
		
		bax = ba;
		caller = callerobject;
		event = eventname;
		if (bax instanceof BA) {
			if (caller != null) {
				if (event instanceof String) {
					if (!event.isEmpty()) {
						need_log_event = bax.subExists(event+"_log");
						need_connected_event = bax.subExists(event+"_connected");
						need_disconnected_event = bax.subExists(event+"_disconnected");
						need_newmapdata_event = bax.subExists(event+"_newmapdata");
						need_newbytesdata_event = bax.subExists(event+"_newbytesdata");
						need_newstringdata_event = bax.subExists(event+"_newstringdata");
						need_newintsdata_event = bax.subExists(event+"_newintsdata");
						
						_inited = true;
					} 
				} 
			} 
		} 
			
	}
	
	/**
	 * Check if Events already Initialized for this TCPSocket
	 * @return true if already initialized
	 */
	public boolean EventsInitialized() {
		return _inited;
	}
	
	/**
	 * Check if TCPSocket is connected 
	 * @return true if connected
	 */
	public boolean IsConnected() {
		if (_socket instanceof Socket) {
			if (!_socket.isClosed()) {
				return _socket.isConnected();
			}
		}
		return false;
	}
	
	
	
	/**
	 * Connect to Target Ip and Target Port
	 * Will raise Connected(remoteip as string, remoteport as int) event
	 * if TCPSocket connected, remoteip has ip address, and remoteport != 0;
	 * @param targetip : target ip
	 * @param port : target port
	 */
	public void ConnectTo(String targetip, int port) {
		if (targetip instanceof String) {
			if (!targetip.isEmpty()) {
				if (port>0) {
					try {
						Socket trysock = new Socket();
						SocketAddress rem = new InetSocketAddress(targetip, port);
						trysock.connect(rem);
						Thread tx = new Thread(new tcpsocketrun(trysock));
						tx.start();
						return;
					} catch (UnknownHostException e) {
						raise_log_error("UnknownHostException on ConnectTo ["+targetip+":"+port+"]",e);
						
					} catch (IOException e) {
						raise_log_error("IOException on ConnectTo ["+targetip+"]:["+port+"]",e);
					}
				}
			}
		}
		raise_connected("",0);
	}
	
	/**
	 * Connect to Target IP and Target Port , and specifically bind to Local IP address
	 * Will raise Connected(remoteip as string, remoteport as int) event
	 * if TCPSocket connected, remoteip has ip address, and remoteport != 0;
	 * @param targetip remote IP address
	 * @param port remote port
	 * @param localip local IP address
	 */
	public void  ConnectTo_withBind(String targetip, int port, String localip) {
		if (targetip instanceof String && !targetip.isEmpty()) {
			if (port>0) {
				
				try {
					SocketAddress loc = new InetSocketAddress(localip, port);
					SocketAddress rem = new InetSocketAddress (targetip,port);
					Socket trysocket = new Socket();
					trysocket.bind(loc);
					trysocket.connect(rem);
					Thread tx = new Thread(new tcpsocketrun(trysocket));
					tx.start();
					return;
				} catch(IOException e) {
					raise_log_error("IOException on ConnectTO ["+targetip+"]:]"+port+"] Bind="+localip,e);
				}
				
			}
		}
		raise_connected("",0);
	}
	
	private class tcpsocketrun implements Runnable{
		private boolean validrun = false;
		private ByteBuffer bytebuf ;
		public tcpsocketrun(Socket xx) { 
			// reset
			_remoteip = "";
			_remoteport = 0;
			_localip = "";
			_localport = 0;
			_connected_tick = 0;
			_rxbytescount = 0;
			_rxokcount = 0;
			_txbytescount = 0;
			_txokcount = 0;
			_txtimercount = 0;
			_rxtimercount = 0;
			_mapkey = "";
			if (onesecondtimer instanceof Timer) {
				onesecondtimer.cancel();
			}
			onesecondtimer = null;

			try {
				BA.Log("tcpsocketrun about to call Disconnect on previous _socket if available");
				if (_socket instanceof Socket) Disconnect();
				_socket = xx;
				
				
				ObjectInputStream _in = new ObjectInputStream(_socket.getInputStream());
				ObjectOutputStream _out = new ObjectOutputStream(_socket.getOutputStream());
				
				
				bytebuf = ByteBuffer.allocate(1024);
				_input = _in;
				_output = _out;
				
				
				_remoteip = _socket.getInetAddress() == null ? "" : _socket.getInetAddress().getHostAddress();
				_localip = _socket.getLocalAddress() == null ? "" : _socket.getLocalAddress().getHostAddress();
				_remoteport = _socket.isClosed() ? 0 : _socket.getPort();
				_localport = _socket.getLocalPort();
				_mapkey = _remoteip+":"+String.valueOf(_remoteport);
				validrun = true;
				
			} catch(IOException e) {
				raise_log_error("IOException on tcpsocketrun",e);
			}
			
			if (validrun) {
				/// shutdownhook taruh di sini, buat auto close ketika socket connected
				Runtime.getRuntime().addShutdownHook(new Thread() {
					public void run() {
						BA.Log("ShutdownHook on TCPSocket identifier="+_mapkey);
						Disconnect();
					}
				});
			}
			
		}
		
		public void run() {
			if (validrun) {
				raise_log("tcpsocketrun running");
				raise_connected(_socket.getInetAddress().getHostAddress(), _socket.getPort());
				_connected_tick = DateTime.getNow();
				// create new timer
				onesecondtimer = new Timer();
				onesecondtimer.scheduleAtFixedRate(checktxrxtimercounter, 1000, 1000);
				
				
				
				while(true) {
					// selama _input masih ObjectInputStream, coba looping lagi
					// Kalau Disconnect dipanggil, _input akan jadi null
					// maka while ini jadi break
					
					if (_input instanceof ObjectInputStream) {
						int readcount = 0;
						while(true) {
							// coba ambil data di sini , loop terus sampe break
							// kondisi terus, kalau readcount = buf.length, artinya bisa ambil data banyak 
							// mungkin masih ada data lagi setelahnya
							//
							// kondisi break, kalau readcount < buf.length, artinya buffernya aja gak sampe penuh
							// berarti datanya cuma dikit
							
							byte[] buf = new byte[1024]; 
							
							try {
								readcount = _input.read(buf);	// ngeblok di sini
							} catch (IOException e) {
								raise_log_error("IOException from input.read", e);
								
								// kalau gak online, connection closed
								String msg = e.getMessage();
								
								// Network disconnect
								if (msg.equalsIgnoreCase("connection reset")) {
									Disconnect();		// Disconnect di sini nutup _input, _output, dan _socket
									break; // keluar dari while(true) dalam
								}
								break;
							}  
							
							if (readcount>0) {			// perlu disimpen apa nggak
								_rxtimercount = 0;			// kalau berhasil terima something, reset jadi zero
								if (readcount>bytebuf.remaining()) {
									// gak cukup, gedein byte buffer
									ByteBuffer newbytebuf = ByteBuffer.allocate(bytebuf.capacity()+readcount);
									newbytebuf.put(bytebuf);
									bytebuf = newbytebuf;  // alokasikan ke sini 
								
								}
								
								// sampe sini , size cukup
								bytebuf.put(buf,0,readcount);
								
								// kalau readcount = 1024, artinya datanya cukup banyak, mungkin perlu baca lagi
								// kalau readcount < 1024, artinya datanya sedikit, cukup sekali baca aja 
								if (readcount!=buf.length) break;
							};
							
						}
						
						if (bytebuf.position()>0) {
							bytebuf.flip();
							byte[] totalbytes = new byte[bytebuf.remaining()];
							bytebuf.get(totalbytes);
							//printbytes("TotalBytes",totalbytes);
							try {
								Object rxobject = _converter.ConvertBytesToObject(totalbytes);
								if (rxobject instanceof MyMap) {
									// MyMap adalah class asli Map Java , diwrapping oleh Erel
									Map result = new Map();			// Map ini adalah B4X object
									result.setObject((MyMap)rxobject);
									raise_newmapdata(result);
								} else if (rxobject instanceof String) {
									String result = (String) rxobject;
									raise_newstringdata(result);
								} else if (rxobject instanceof ArrayList) {
									ArrayList<?> tempresult = (ArrayList<?>) rxobject;
									if (tempresult.size()>0) {
										Object _firstobject = tempresult.get(0);
										if (_firstobject instanceof Byte) {
											byte[] result = new byte[tempresult.size()];
											for(int ii=0;ii<result.length;ii++) result[ii] = (byte)tempresult.get(ii);
											raise_newbytesdata(result);
										} else if (_firstobject instanceof Integer) {
											int[] result = new int[tempresult.size()];
											for(int ii=0;ii<result.length;ii++) result[ii] = (int)tempresult.get(ii);
											raise_newintsdata(result);
										}
									} 
								} else {
									raise_log("rxobject is unknown data");
								}
								
								// sampe sini gak ada salah proses, rxokcount++, rxbytescount++
								if (_rxokcount<Integer.MAX_VALUE) _rxokcount++; else _rxokcount = 0;
								if (Long.sum(_rxbytescount, totalbytes.length)<0) {
									// overflow
									_rxbytescount = totalbytes.length;
								} else {
									// not overflow
									_rxbytescount += totalbytes.length;
								}
								
							} catch (IOException e) {
								raise_log_error("IOException on converter.ReadObject",e);
							} 
							
							// bersihin untuk proses selanjutnya
							bytebuf.clear();
						}
					} else {
						BA.Log("_input is not ObjectInputStream");
						break;
					}
				}
				raise_disconnected(_remoteip, _remoteport);
				
				// matiin timer di sini 
				if (onesecondtimer instanceof Timer) {
					onesecondtimer.cancel();
				}
				onesecondtimer = null;

			} else raise_disconnected("",0);
			raise_log("tcpsocketrun ended");
			
		}
	};
	
	@SuppressWarnings("unused")
	private void printbytes(String prefix, byte[] value) {
		StringBuilder str = new StringBuilder();
		str.append(prefix).append(" : ");
		if (value!=null) {
			if (value.length>0) {
				for (int ii=0;ii<value.length;ii++) {
					str.append(Bit.ToHexString(Bit.And(value[ii], 0xFF))).append(" ");
				}
			}
		}
		BA.Log(str.toString());
	}
	
	/**
	 * Disconnect from Remote IP and Remote Port
	 * Will raise Disconnected(remoteip as string, remoteport as int) event
	 * If TCPSocket never connected yet, remoteip = "" , and remoteport = 0
	 */
	public void Disconnect() {
		Close_ObjectOutputStream();
		Close_ObjectInputStream();
		
		if (_socket instanceof Socket) {
			try {
				_socket.close();
				
			} catch (IOException e) {
				raise_log_error("IOException on Disconnect",e);
				raise_disconnected("",0);
			}
		} else {
			raise_log("Disconnect has not effect, Socket is not created yet");
			raise_disconnected("",0);
		}
		
		_socket = null;
		_remoteip = "";
		_localip = "";
		_remoteport =0;
		_localport = 0;
		
		if (_javaevent instanceof TcpSocketJavaEvent) {
			_javaevent.disconnected(getIdentifierKey());
		}
	}
	
	/**
	 * Write Map Object to TCPSocket
	 * @param value : Map value
	 * @return true if can be sent
	 */
	public boolean WriteMap(Map value) {
		if (value instanceof Map) {
			if (value.IsInitialized()) {
				if (value.getSize()>0) {
					try {
						return WriteObject(value);
					} catch (IOException e) {
						raise_log_error("IOException on WriteMap",e);
						Disconnect();
					}
				} else raise_log("WriteMap canceled, value Size = 0");
			} else raise_log("WriteMap canceled, value is not initialized");
		} else raise_log("WriteMap canceled, value is not Map");
		return false;
	}
	
	/**
	 * Write bytes array to TCPSocket
	 * @param value : bytes array
	 * @return true if can be sent
	 */
	public boolean WriteBytes(byte[] value) {
		if (value instanceof byte[]) {
			if (value.length>0) {
				try {
					ArrayList<Byte> result = new ArrayList<Byte>();
					for(int ii=0;ii<value.length;ii++) result.add(value[ii]);
					return WriteObject(result);
				} catch (IOException e) {
					raise_log_error("IOException on WriteBytes",e);
					Disconnect();
				}
			} else raise_log("WriteBytes canceled, value size = 0");
		} else raise_log("WriteBytes canceled, value is not byte array");
		return false;
	}
	
	/**
	 * Write String to TCPSocket
	 * @param value : String value
	 * @return true if can be sent
	 */
	public boolean WriteString(String value) {
		if (value instanceof String) {
			if (!value.isEmpty()) {
				try {
					return WriteObject(value);
				} catch (IOException e) {
					raise_log_error("IOException on WriteString",e);
					Disconnect();
				}	
			} else raise_log("WriteString canceled, value size = 0");
		} else raise_log("WriteString canceled, value is not String");
		
		return false;
	}
	
	/**
	 * Write Int array to TCPSocket
	 * @param value : int array
	 * @return true if can be sent
	 */
	public boolean WriteInts(int[] value) {
		if (value instanceof int[]) {
			if (value.length>0) {
				try {
					ArrayList<Integer> result = new ArrayList<Integer>();
					for(int ii=0;ii<value.length;ii++) result.add(value[ii]);
					return WriteObject(result);
				} catch (IOException e) {
					raise_log_error("IOException on WriteInts",e);
					Disconnect();
				}
			} else raise_log("WriteInts canceled, value size = 0");
		} else raise_log("WriteInts canceled, value is not int array");
		
		return false;
	}
	
	/**
	 * Class utama untuk Kirim ke Socket
	 * @param value : Object yang akan dikirim
	 * @return true kalau success
	 * @throws IOException kalau gagal
	 */
	private boolean WriteObject(Object value) throws IOException {
		if (IsConnected()) {
			if (_output instanceof ObjectOutputStream) {
				byte[] outputbyte = _converter.ConvertObjectToBytes(value);
				_output.write(outputbyte);
				_output.flush();
				//printbytes("WriteObject",outputbyte);
				
				// sampe sini semua ok, gak ada exception
				if (_txokcount<Integer.MAX_VALUE) _txokcount++; else _txokcount = 0;
				if (Long.sum(_txbytescount, outputbyte.length)<0) {
					// overflow
					_txbytescount = outputbyte.length;
				} else {
					// not overflow
					_txbytescount+= outputbyte.length;
				}
				_txtimercount=0; 			// kalau berhasil kirim, reset to zero
				return true;
			}
		}
		return false;
	}
	
	/**
	 * Return Remote IP Address
	 * @return empty string if disconnected
	 */
	public String getRemoteIPAddress() {
		return _remoteip;
	}
	
	/**
	 * Return Remote TCP Port
	 * @return zero if disconnected
	 */
	public int getRemotePort() {
		return _remoteport;
	}
	
	/**
	 * Get Local IP address used by TCPSocket
	 * @return empty string if not available
	 */
	public String getLocalIPAddress() {
		return _localip;
	}
	
	/**
	 * Get Local TCP Port used by TCPSocket
	 * @return zero if not available
	 */
	public int getLocalPort() {
		return _localport;
	}
	
	/**
	 * Get InputStream object of TCPSocket
	 * @return InputStream if valid, or null if invalid
	 */
	public InputStream getInputStream() {
		if (IsConnected()) {
			try {
				return _socket.getInputStream();
			} catch (IOException e) {
				raise_log_error("IOException on getInputStream",e);
			}
		}
		return null;
	}
	
	/**
	 * Get OutputStream object of TCPSocket
	 * @return OutputStream if valid, or null if invalid
	 */
	public OutputStream getOutputStream() {
		if (IsConnected()) {
			try {
				return _socket.getOutputStream();
			} catch (IOException e) {
				raise_log_error("IOException on getOutputStream",e);
			}
		}
		return null;
	}
	
	/**
	 * Return how many seconds since first time connected
	 * @return duration in seconds
	 */
	public long ConnectionDuration() {
		if (_connected_tick>0) {
			long now = DateTime.getNow();
			if ((now - _connected_tick)>0) {
				return ((now - _connected_tick) / DateTime.TicksPerSecond);
			}
		}
		return 0;
	}
	
	/**
	 * Get Date and Time when the socket is connected
	 * String will formatted as DD-MM-YYYY HH:MM:SS
	 * @return empty string if not yet connected
	 */
	public String Connected_DateTime_String() {
		if (_connected_tick>0) {
			return mycodes.Tick_To_DDMMYYYY_HHMMSS(_connected_tick);
		}
		return "";
	}
	
	/**
	 * Get how many succesful Sending package
	 * @return zero to 2,147,483,647
	 */
	public int getTX_OK_Counter() {
		
		return _txokcount;
	}
	
	/**
	 * Get how many succesful Receiving package
	 * @return zero to 2,147,483,647
	 */
	public int getRX_OK_Counter() {
		return _rxokcount;
	}
	
	/**
	 * Get how much bytes has been sucesfully sent
	 * @return value in bytes, from zero to 9,223,372,036,854,775,807
	 */
	public long getTX_Bytes_Counter() {
		return _txbytescount;
	}
	
	/**
	 * Get how much bytes has been sucesfully received
	 * @return value in bytes, from zero to 9,223,372,036,854,775,807
	 */
	public long getRX_Bytes_Counter() {
		return _rxbytescount;
	}
	
	/**
	 * TimerTask to execute, for checking txtimercounter and rxtimercounter
	 * this TimerTask will execute every 1000 ms
	 */
	private TimerTask checktxrxtimercounter = new TimerTask() {

		@Override
		public void run() {
			if (_txtimercount>5) {
				raise_log("Not sending more than 5 seconds, sending [PING] to check connection");
				WriteString("[PING]");		// pancingan aja, gak penting hasilnya
			}
			
			if (_rxtimercount>10) {
				raise_log("Not receiving more than 10 seconds, connection have problem");
				Disconnect();
			}
		}
		
	};
	
	private void Close_ObjectOutputStream() {
		if (_output instanceof ObjectOutputStream) {
			try {
				_output.close();
				raise_log("ObjectOutputStream closed");
			} catch (IOException e) {
				raise_log_error("IOException on Close_ObjectOutputStream",e);
			}
		}
		_output = null;
	}
	
	private void Close_ObjectInputStream() {
		if (_input instanceof ObjectInputStream) {
			try {
				_input.close();
				raise_log("ObjectInputStream closed");
			} catch (IOException e) {
				raise_log_error("IOException on Close_ObjectInputStream",e);
			}
		}
		_input = null;
	}
	
	private void raise_log_error(String msg, Exception e) {
		StringBuffer sbf = new StringBuffer();
		sbf.append(msg);
		if (e instanceof Exception) {
			if (e.getMessage() instanceof String) {
				sbf.append("\r\n");
				sbf.append("Msg : "+e.getMessage());
			}
			if (e.getCause() instanceof Throwable) {
				sbf.append("\r\n");
				sbf.append(e.getCause());
			}
		}
		raise_log(sbf.toString());
		e.printStackTrace();
	}
	
	private void raise_log(String msg) {
		if (need_log_event) bax.raiseEventFromDifferentThread(Me, null, 0, event+"_log", false, new Object[] {msg});
	}
	
	private void raise_connected(String remoteip, int remoteport) {
		if (need_connected_event) {
			bax.raiseEventFromDifferentThread(Me, null, 0, event+"_connected", false, new Object[] {remoteip, remoteport});
		}
	}
	
	private void raise_disconnected(String remoteip, int remoteport) {
		if (need_disconnected_event) {
			long tt = 0;
			if (_connected_tick > 0) {
				tt = (DateTime.getNow() - _connected_tick) / DateTime.TicksPerSecond;
			}
			bax.raiseEventFromDifferentThread(Me, null, 0, event+"_disconnected", false, new Object[] {remoteip,remoteport, tt / DateTime.TicksPerSecond });
		}
	}
	
	private void raise_newmapdata(Map value) {
		if (need_newmapdata_event) {
			bax.raiseEventFromDifferentThread(Me, null, 0, event+"_newmapdata", false, new Object[] {value});
		}
	}
	
	private void raise_newbytesdata(byte[] value) {
		if (need_newbytesdata_event) {
			bax.raiseEventFromDifferentThread(Me, null, 0, event+"_newbytesdata", false, new Object[] {value});
		}
	}
	
	private void raise_newstringdata(String value) {
		if (need_newstringdata_event) {
			bax.raiseEventFromDifferentThread(Me, null, 0, event+"_newstringdata", false, new Object[] {value});
		}
	}
	
	private void raise_newintsdata(int[] value) {
		if (need_newintsdata_event) {
			bax.raiseEventFromDifferentThread(Me, null, 0, event+"_newintsdata", false, new Object[] {value});
		}
	}
	
}
