/*
 * Copyright (c) 2010 Nathan Rajlich (https://github.com/TooTallNate)
 * Copyright (c) 2010 Animesh Kumar (https://github.com/anismiles)
 * Copyright (c) 2010 Strumsoft (https://strumsoft.com)
 *
 * Permission is hereby granted, free of charge, to any person
 * obtaining a copy of this software and associated documentation
 * files (the "Software"), to deal in the Software without
 * restriction, including without limitation the rights to use,
 * copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the
 * Software is furnished to do so, subject to the following
 * conditions:
 *
 * The above copyright notice and this permission notice shall be
 * included in all copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
 * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
 * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
 * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
 * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
 * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
 * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
 * OTHER DEALINGS IN THE SOFTWARE.
 */
package com.strumsoft.websocket.phonegap;

import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.net.InetSocketAddress;
import java.net.URI;
import java.nio.ByteBuffer;
import java.nio.channels.NotYetConnectedException;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.SocketChannel;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Iterator;
import java.util.Random;
import java.util.Set;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;

import android.app.Activity;
import android.content.Context;
import android.os.Handler;
import android.os.Message;
import android.util.Log;
import android.view.inputmethod.InputMethodManager;
import android.webkit.WebView;

/**
 * The <tt>WebSocket</tt> is an implementation of WebSocket Client API, and
 * expects a valid "ws://" URI to connect to. When connected, an instance
 * recieves important events related to the life of the connection, like
 * <var>onOpen</var>, <var>onClose</var>, <var>onError</var> and
 * <var>onMessage</var>. An instance can send messages to the server via the
 * <var>send</var> method.
 *
 * @author Animesh Kumar
 */
public class WebSocket implements Runnable {

	/**
	 * Enum for WebSocket Draft
	 */
	public enum Draft {
		DRAFT75, DRAFT76
	}

	// //////////////// CONSTANT
	/**
	 * The connection has not yet been established.
	 */
	public final static int WEBSOCKET_STATE_CONNECTING = 0;
	/**
	 * The WebSocket connection is established and communication is possible.
	 */
	public final static int WEBSOCKET_STATE_OPEN = 1;
	/**
	 * The connection is going through the closing handshake.
	 */
	public final static int WEBSOCKET_STATE_CLOSING = 2;
	/**
	 * The connection has been closed or could not be opened.
	 */
	public final static int WEBSOCKET_STATE_CLOSED = 3;

	/**
	 * An empty string
	 */
	private static String BLANK_MESSAGE = "";
	/**
	 * The javascript method name for onOpen event.
	 */
	private static String EVENT_ON_OPEN = "onopen";
	/**
	 * The javascript method name for onMessage event.
	 */
	private static String EVENT_ON_MESSAGE = "onmessage";
	/**
	 * The javascript method name for onClose event.
	 */
	private static String EVENT_ON_CLOSE = "onclose";
	/**
	 * The javascript method name for onError event.
	 */
	private static String EVENT_ON_ERROR = "onerror";
	/**
	 * The default port of WebSockets, as defined in the spec.
	 */
	public static final int DEFAULT_PORT = 80;
	/**
	 * The WebSocket protocol expects UTF-8 encoded bytes.
	 */
	public static final String UTF8_CHARSET = "UTF-8";
	/**
	 * The byte representing Carriage Return, or \r
	 */
	public static final byte DATA_CR = (byte) 0x0D;
	/**
	 * The byte representing Line Feed, or \n
	 */
	public static final byte DATA_LF = (byte) 0x0A;
	/**
	 * The byte representing the beginning of a WebSocket text frame.
	 */
	public static final byte DATA_START_OF_FRAME = (byte) 0x00;
	/**
	 * The byte representing the end of a WebSocket text frame.
	 */
	public static final byte DATA_END_OF_FRAME = (byte) 0xFF;

	// //////////////// INSTANCE Variables
	/**
	 * The WebView instance from Phonegap DroidGap
	 */
	private final WebView appView;
	/**
	 * The unique id for this instance (helps to bind this to javascript events)
	 */
	private String id;
	/**
	 * The URI this client is supposed to connect to.
	 */
	private URI uri;
	/**
	 * The port of the websocket server
	 */
	private int port;
	/**
	 * The Draft of the WebSocket protocol the Client is adhering to.
	 */
	private Draft draft;
	/**
	 * The <tt>SocketChannel</tt> instance to use for this server connection.
	 * This is used to read and write data to.
	 */
	private SocketChannel socketChannel;
	/**
	 * The 'Selector' used to get event keys from the underlying socket.
	 */
	private Selector selector;
	/**
	 * Keeps track of whether or not the client thread should continue running.
	 */
	private boolean running;
	/**
	 * Internally used to determine whether to recieve data as part of the
	 * remote handshake, or as part of a text frame.
	 */
	private boolean handshakeComplete;
	/**
	 * The 1-byte buffer reused throughout the WebSocket connection to read
	 * data.
	 */
	private ByteBuffer buffer;
	/**
	 * The bytes that make up the remote handshake.
	 */
	private ByteBuffer remoteHandshake;
	/**
	 * The bytes that make up the current text frame being read.
	 */
	private ByteBuffer currentFrame;
	/**
	 * Queue of buffers that need to be sent to the client.
	 */
	private BlockingQueue<ByteBuffer> bufferQueue;
	/**
	 * Lock object to ensure that data is sent from the bufferQueue in the
	 * proper order
	 */
	private Object bufferQueueMutex = new Object();
	/**
	 * Number 1 used in handshake
	 */
	private int number1 = 0;
	/**
	 * Number 2 used in handshake
	 */
	private int number2 = 0;
	/**
	 * Key3 used in handshake
	 */
	private byte[] key3 = null;
	/**
	 * The readyState attribute represents the state of the connection.
	 */
	private int readyState = WEBSOCKET_STATE_CONNECTING;

	private boolean keyboardIsShowing = false;

	private Handler handler = null;

	private final WebSocket instance;

	private ByteBuffer bigBuffer = ByteBuffer.allocate(1024 * 500);
	private byte[] tokenByteBuffer = new byte[1024 * 500];
	private int tokenByteBufferCounter = 0;

	/**
	 * Constructor.
	 *
	 * Note: this is protected because it's supposed to be instantiated from {@link WebSocketFactory} only.
	 *
	 * @param appView
	 *            {@link android.webkit.WebView}
	 * @param uri
	 *            websocket server {@link URI}
	 * @param draft
	 *            websocket server {@link Draft} implementation (75/76)
	 * @param id
	 *            unique id for this instance
	 */
	protected WebSocket(Handler handler, WebView appView, URI uri, Draft draft, String id) {
		this.appView = appView;
		this.uri = uri;
		this.draft = draft;
		this.handler = handler;

		// port
		port = uri.getPort();
		if (port == -1) {
			port = DEFAULT_PORT;
		}

		// Id
		this.id = id;

		this.bufferQueue = new LinkedBlockingQueue<ByteBuffer>();
		this.handshakeComplete = false;
		this.remoteHandshake = this.currentFrame = null;
		this.buffer = ByteBuffer.allocate(1);

		this.instance = this;
	}

	// //////////////////////////////////////////////////////////////////////////////////////
	// /////////////////////////// WEB SOCKET API Methods
	// ///////////////////////////////////
	// //////////////////////////////////////////////////////////////////////////////////////
	/**
	 * Starts a new Thread and connects to server
	 *
	 * @throws IOException
	 */
	public Thread connect() throws IOException {
		this.running = true;
		this.readyState = WEBSOCKET_STATE_CONNECTING;
		// open socket
		socketChannel = SocketChannel.open();
		socketChannel.configureBlocking(false);
		// set address
		socketChannel.connect(new InetSocketAddress(uri.getHost(), port));
		// start a thread to make connection

		// More info:
		// http://groups.google.com/group/android-developers/browse_thread/thread/45a8b53e9bf60d82
		// http://stackoverflow.com/questions/2879455/android-2-2-and-bad-address-family-on-socket-connect
		System.setProperty("java.net.preferIPv4Stack", "true");
		System.setProperty("java.net.preferIPv6Addresses", "false");

		selector = Selector.open();
		socketChannel.register(selector, SelectionKey.OP_CONNECT);
		Log.v("websocket", "Starting a new thread to manage data reading/writing");

		Thread th = new Thread(this);
		th.start();
		// return thread object for explicit closing, if needed
		return th;
	}

	public void run() {
		while (this.running) {
			try {
				_connect();
			} catch (IOException e) {
				e.printStackTrace();
				this.onError(e);
			}
		}
	}


	public void setKeyboardStatus(boolean status){
		keyboardIsShowing = status;
		Log.d("websocket", "keyboardIsShowing: "+keyboardIsShowing);
	}
	/**
	 * Closes connection with server
	 */
	public void close() {
		this.readyState = WebSocket.WEBSOCKET_STATE_CLOSING;

		// close socket channel
		try {
			this.socketChannel.close();
		} catch (IOException e) {
			this.onError(e);
		}
		this.running = false;
		selector.wakeup();

		// fire onClose method
		this.onClose();

		this.readyState = WebSocket.WEBSOCKET_STATE_CLOSED;
	}

	/**
	 * Sends <var>text</var> to server
	 *
	 * @param text
	 *            String to send to server
	 */
	public void send(final String text) {
		new Thread(new Runnable() {
			@Override
			public void run() {
				if (instance.readyState == WEBSOCKET_STATE_OPEN) {
					try {
						instance._send(text);
					} catch (Exception e) {
						instance.onError(e);
					}
				} else {
					instance.onError(new NotYetConnectedException());
				}
			}
		}).start();
	}

	/**
	 * Called when an entire text frame has been received.
	 *
	 * @param msg
	 *            Message from websocket server
	 */
	public void onMessage(String msg) {
		final String data = msg;
		Log.v("websocket", "Received a message: " + msg);
		appView.post(new Runnable() {
	        public void run() {
	            if(keyboardIsShowing){
	            	Message message = new Message();
	            	message.obj = buildJavaScriptData(EVENT_ON_MESSAGE, data);
	            	message.what = 3;
	            	handler.sendMessage(message);
	            } else {
	            	appView.loadUrl(buildJavaScriptData(EVENT_ON_MESSAGE, data));
	            }
	        }
	    });
	}

	public void onOpen() {
		Log.v("websocket", "Connected!");
		appView.post(new Runnable() {
	        public void run() {
	            appView.loadUrl(buildJavaScriptData(EVENT_ON_OPEN, BLANK_MESSAGE));
	            if(keyboardIsShowing){
	            	handler.sendEmptyMessage(3);
	            }
	        }
	    });
	}

	public void onClose() {
		appView.post(new Runnable() {
	      public void run() {
	          appView.loadUrl(buildJavaScriptData(EVENT_ON_CLOSE, BLANK_MESSAGE));
	          if(keyboardIsShowing){
	          	handler.sendEmptyMessage(3);
	          }
	      }
    });
	}

	public void onError(Throwable t) {
		final String msg = t.getMessage();
		Log.v("websocket", "Error: " + msg);
		t.printStackTrace();
		appView.post(new Runnable() {
	        public void run() {
	            appView.loadUrl(buildJavaScriptData(EVENT_ON_ERROR, msg));
	            if(keyboardIsShowing){
	            	handler.sendEmptyMessage(3);
	            }
	        }
	    });
	}

	public String getId() {
		return id;
	}

	/**
	 * @return the readyState
	 */
	public int getReadyState() {
		return readyState;
	}

	/**
	 * Builds text for javascript engine to invoke proper event method with
	 * proper data.
	 *
	 * @param event
	 *            websocket event (onOpen, onMessage etc.)
	 * @param msg
	 *            Text message received from websocket server
	 * @return
	 */
	private String buildJavaScriptData(String event, String msg) {
		String b64EncodedMsg = "Error!";
		try{
			if(msg != null) {
				b64EncodedMsg = Base64.encodeBytes(msg.getBytes(UTF8_CHARSET));
			}
		} catch(Exception e) {
			e.printStackTrace();
		}
		String _d = "javascript:WebSocket." + event + "(" + "{" + "\"_target\":\"" + id + "\","
				+ "\"_data\":'" + b64EncodedMsg + "'" + "}" + ")";
		return _d;
	}

	// //////////////////////////////////////////////////////////////////////////////////////
	// /////////////////////////// WEB SOCKET Internal Methods
	// //////////////////////////////
	// //////////////////////////////////////////////////////////////////////////////////////

	private boolean _send(String text) throws IOException {
		if (!this.handshakeComplete) {
			throw new NotYetConnectedException();
		}
		if (text == null) {
			throw new NullPointerException("Cannot send 'null' data to a WebSocket.");
		}

		// Get 'text' into a WebSocket "frame" of bytes
		byte[] textBytes = text.getBytes(UTF8_CHARSET.toString());
		ByteBuffer b = ByteBuffer.allocate(textBytes.length + 2);
		b.put(DATA_START_OF_FRAME);
		b.put(textBytes);
		b.put(DATA_END_OF_FRAME);
		b.rewind();

		// See if we have any backlog that needs to be sent first
		if (_write()) {
			// Write the ByteBuffer to the socket
			this.socketChannel.write(b);
		}

		// If we didn't get it all sent, add it to the buffer of buffers
		if (b.remaining() > 0) {
			if (!this.bufferQueue.offer(b)) {
				throw new IOException("Buffers are full, message could not be sent to"
						+ this.socketChannel.socket().getRemoteSocketAddress());
			}
			return false;
		}
		return true;
	}

	// actual connection logic
	private void _connect() throws IOException {
		// Continuous loop that is only supposed to end when "close" is called.

		selector.select();
		Set<SelectionKey> keys = selector.selectedKeys();
		Iterator<SelectionKey> i = keys.iterator();

		while (i.hasNext()) {
			SelectionKey key = i.next();
			i.remove();
			if (key.isConnectable()) {
				if (socketChannel.isConnectionPending()) {
					socketChannel.finishConnect();
				}
				socketChannel.register(selector, SelectionKey.OP_READ);
				_writeHandshake();
			}
			if (key.isReadable()) {
				try {
					_read();
				} catch (NoSuchAlgorithmException nsa) {
					this.onError(nsa);
				}
			}
		}

	}

	private void _writeHandshake() throws IOException {
		String path = this.uri.getPath();
		if (path.indexOf("/") != 0) {
			path = "/" + path;
		}

		String host = uri.getHost() + (port != DEFAULT_PORT ? ":" + port : "");
		String origin = "*"; // TODO: Make 'origin' configurable
		String request = "GET " + path + " HTTP/1.1\r\n" + "Upgrade: WebSocket\r\n" + "Connection: Upgrade\r\n"
				+ "Host: " + host + "\r\n" + "Origin: " + origin + "\r\n";

		// Add random keys for Draft76
		if (this.draft == Draft.DRAFT76) {
			request += "Sec-WebSocket-Key1: " + this._randomKey() + "\r\n";
			request += "Sec-WebSocket-Key2: " + this._randomKey() + "\r\n";
			request += "\r\n";
			this.key3 = new byte[8];
			(new Random()).nextBytes(this.key3);

			// Convert to bytes early so last eight bytes don't get jacked
			byte[] bRequest = request.getBytes(UTF8_CHARSET);

			byte[] bToSend = new byte[bRequest.length + 8];

			// Copy in the Request bytes
			System.arraycopy(bRequest, 0, bToSend, 0, bRequest.length);

			// Now tack on key3 bytes
			System.arraycopy(this.key3, 0, bToSend, bRequest.length, this.key3.length);

			// Now we can send all keys as a single frame
			_write(bToSend);
			return;
		}

		request += "\r\n";
		_write(request.getBytes(UTF8_CHARSET));
	}

	private boolean _write() throws IOException {
		synchronized (this.bufferQueueMutex) {
			ByteBuffer buffer = this.bufferQueue.peek();
			while (buffer != null) {
				this.socketChannel.write(buffer);
				if (buffer.remaining() > 0) {
					return false; // Didn't finish this buffer. There's more to
					// send.
				} else {
					this.bufferQueue.poll(); // Buffer finished. Remove it.
					buffer = this.bufferQueue.peek();
				}
			}
			return true;
		}
	}

	private void _write(byte[] bytes) throws IOException {
		this.socketChannel.write(ByteBuffer.wrap(bytes));
	}

	private void _read() throws IOException, NoSuchAlgorithmException {
		int bytesRead = -1;
		try {
			if (!handshakeComplete) {
				buffer.rewind();
				bytesRead = socketChannel.read(this.buffer);
				buffer.rewind();
			} else {
				bigBuffer.rewind();
				bytesRead = socketChannel.read(this.bigBuffer);
				bigBuffer.rewind();
			}

		} catch (Exception ex) {
			Log.v("websocket", "Could not read data from socket channel, ex=" + ex.toString());
		}

		if (bytesRead == -1) {
			Log.v("websocket", "All Bytes readed");
			close();
		} else if (bytesRead > 0) {
			if (!this.handshakeComplete) {
				_readHandshake();
			} else {
				_readFrame(bytesRead);
			}
		}
	}

	private void _readFrame(int bytesRead) throws UnsupportedEncodingException {
		byte[] data = bigBuffer.array();

		int length = bytesRead;
		if (length > 2000) length = 2000;

		Log.v("websocket", "_readFrame - bytesRead: " + bytesRead + ", data: " + new String(data, 0, length));

		// Get tokens
		for (int i=0; i < bytesRead; i++) {

			byte readByte = data[i];

			// Token message is finished
			if (readByte == DATA_END_OF_FRAME) {

				// Make token public
				this.onMessage(new String(tokenByteBuffer, 0, tokenByteBufferCounter));

				// Reset counter for byte buffer
				tokenByteBufferCounter = 0;

			// Bytes to read as token message, skip start frame byte
			} else if (readByte != DATA_START_OF_FRAME) {
				tokenByteBufferCounter++;

				// Array is out of bounds, make it greater
				if (tokenByteBufferCounter == tokenByteBuffer.length) {

					byte[] newTokenByteBuffer = new byte[tokenByteBuffer.length*2];

					Log.v("websocket", "expand token byte buffer, new size=" + newTokenByteBuffer.length);

					// Copy old data
					for (int i2=0; i2<tokenByteBuffer.length; i2++) {
						newTokenByteBuffer[i2] = tokenByteBuffer[i2];
					}

					tokenByteBuffer = newTokenByteBuffer;
				}

				tokenByteBuffer[tokenByteBufferCounter-1] = readByte;
		}
	}

	private void _readHandshake() throws IOException, NoSuchAlgorithmException {
		ByteBuffer ch = ByteBuffer.allocate((this.remoteHandshake != null ? this.remoteHandshake.capacity() : 0)
				+ this.buffer.capacity());
		if (this.remoteHandshake != null) {
			this.remoteHandshake.rewind();
			ch.put(this.remoteHandshake);
		}
		ch.put(this.buffer);
		this.remoteHandshake = ch;
		byte[] h = this.remoteHandshake.array();
		// If the ByteBuffer contains 16 random bytes, and ends with
		// 0x0D 0x0A 0x0D 0x0A (or two CRLFs), then the client
		// handshake is complete for Draft 76 Client.
		if ((h.length >= 20 && h[h.length - 20] == DATA_CR && h[h.length - 19] == DATA_LF
				&& h[h.length - 18] == DATA_CR && h[h.length - 17] == DATA_LF)) {
			_readHandshake(new byte[] { h[h.length - 16], h[h.length - 15], h[h.length - 14], h[h.length - 13],
					h[h.length - 12], h[h.length - 11], h[h.length - 10], h[h.length - 9], h[h.length - 8],
					h[h.length - 7], h[h.length - 6], h[h.length - 5], h[h.length - 4], h[h.length - 3],
					h[h.length - 2], h[h.length - 1] });

			// If the ByteBuffer contains 8 random bytes,ends with
			// 0x0D 0x0A 0x0D 0x0A (or two CRLFs), and the response
			// contains Sec-WebSocket-Key1 then the client
			// handshake is complete for Draft 76 Server.
		} else if ((h.length >= 12 && h[h.length - 12] == DATA_CR && h[h.length - 11] == DATA_LF
				&& h[h.length - 10] == DATA_CR && h[h.length - 9] == DATA_LF)
				&& new String(this.remoteHandshake.array(), UTF8_CHARSET).contains("Sec-WebSocket-Key1")) {// ************************
			_readHandshake(new byte[] { h[h.length - 8], h[h.length - 7], h[h.length - 6], h[h.length - 5],
					h[h.length - 4], h[h.length - 3], h[h.length - 2], h[h.length - 1] });

			// Consider Draft 75, and the Flash Security Policy
			// Request edge-case.
		} else if ((h.length >= 4 && h[h.length - 4] == DATA_CR && h[h.length - 3] == DATA_LF
				&& h[h.length - 2] == DATA_CR && h[h.length - 1] == DATA_LF)
				&& !(new String(this.remoteHandshake.array(), UTF8_CHARSET).contains("Sec"))
				|| (h.length == 23 && h[h.length - 1] == 0)) {
			_readHandshake(null);
		}
	}

	private void _readHandshake(byte[] handShakeBody) throws IOException, NoSuchAlgorithmException {
		// byte[] handshakeBytes = this.remoteHandshake.array();
		// String handshake = new String(handshakeBytes, UTF8_CHARSET);
		// TODO: Do some parsing of the returned handshake, and close connection
		// in received anything unexpected!

		this.handshakeComplete = true;
		boolean isConnectionReady = true;

		/* TODO: verify that this works. Was commented before due to null pointer exception */
		if (this.draft == WebSocket.Draft.DRAFT76) {
			if (handShakeBody == null) {
				isConnectionReady = true;
			}
			byte[] challenge = new byte[] { (byte) (this.number1 >> 24), (byte) ((this.number1 << 8) >> 24),
					(byte) ((this.number1 << 16) >> 24), (byte) ((this.number1 << 24) >> 24),
					(byte) (this.number2 >> 24), (byte) ((this.number2 << 8) >> 24),
					(byte) ((this.number2 << 16) >> 24), (byte) ((this.number2 << 24) >> 24), this.key3[0],
					this.key3[1], this.key3[2], this.key3[3], this.key3[4], this.key3[5], this.key3[6], this.key3[7] };
			MessageDigest md5 = MessageDigest.getInstance("MD5");
			byte[] expected = md5.digest(challenge);
			for (int i = 0; i < handShakeBody.length; i++) {
				if (expected[i] != handShakeBody[i]) {
					isConnectionReady = true;
				}
			}
		}
		/* END */

		if (isConnectionReady) {
			this.readyState = WEBSOCKET_STATE_OPEN;
			// fire onOpen method
			this.onOpen();
		} else {
			close();
		}
	}

	private String _randomKey() {
		Random r = new Random();
		long maxNumber = 4294967295L;
		long spaces = r.nextInt(12) + 1;
		int max = new Long(maxNumber / spaces).intValue();
		max = Math.abs(max);
		int number = r.nextInt(max) + 1;
		if (this.number1 == 0) {
			this.number1 = number;
		} else {
			this.number2 = number;
		}
		long product = number * spaces;
		String key = Long.toString(product);
		int numChars = r.nextInt(12);
		for (int i = 0; i < numChars; i++) {
			int position = r.nextInt(key.length());
			position = Math.abs(position);
			char randChar = (char) (r.nextInt(95) + 33);
			// exclude numbers here
			if (randChar >= 48 && randChar <= 57) {
				randChar -= 15;
			}
			key = new StringBuilder(key).insert(position, randChar).toString();
		}
		for (int i = 0; i < spaces; i++) {
			int n = key.length() - 1;
			int position;
			if(n == 0) {
				position = 1;
			} else {
				position = r.nextInt(n) + 1;
			}
			key = new StringBuilder(key).insert(position, "\u0020").toString();
		}
		return key;
	}
	
}
