/*
 * Copyright (c) 2009-2010 Digital Bazaar, Inc. All rights reserved.
 *
 * @author Dave Longley
 */
package
{
   import flash.display.Sprite;

   /**
    * A SocketPool is a flash object that can be embedded in a web page to
    * allow javascript access to pools of Sockets.
    *
    * Javascript can create a pool and then as many Sockets as it desires. Each
    * Socket will be assigned a unique ID that allows continued javascript
    * access to it. There is no limit on the number of persistent socket
    * connections.
    */
   public class SocketPool extends Sprite
   {
      import flash.events.Event;
      import flash.events.EventDispatcher;
      import flash.errors.IOError;
      import flash.events.IOErrorEvent;
      import flash.events.ProgressEvent;
      import flash.events.SecurityErrorEvent;
      import flash.events.TextEvent;
      import flash.external.ExternalInterface;
      import flash.net.SharedObject;
      import flash.system.Security;
      import flash.utils.ByteArray;
      import mx.utils.Base64Decoder;
      import mx.utils.Base64Encoder;

      // a map of ID to Socket
      private var mSocketMap:Object;

      // a counter for Socket IDs (Note: assumes there will be no overflow)
      private var mNextId:uint;

      // an event dispatcher for sending events to javascript
      private var mEventDispatcher:EventDispatcher;

      /**
       * Creates a new, unitialized SocketPool.
       *
       * @throws Error - if no external interface is available to provide
       *                 javascript access.
       */
      public function SocketPool()
      {
         if(!ExternalInterface.available)
         {
            trace("ExternalInterface is not available");
            throw new Error(
               "Flash's ExternalInterface is not available. This is a " +
               "requirement of SocketPool and therefore, it will be " +
               "unavailable.");
         }
         else
         {
            try
            {
               // set up javascript access:

               // initializes/cleans up the SocketPool
               ExternalInterface.addCallback("init", init);
               ExternalInterface.addCallback("cleanup", cleanup);

               // creates/destroys a socket
               ExternalInterface.addCallback("create", create);
               ExternalInterface.addCallback("destroy", destroy);

               // connects/closes a socket
               ExternalInterface.addCallback("connect", connect);
               ExternalInterface.addCallback("close", close);

               // checks for a connection
               ExternalInterface.addCallback("isConnected", isConnected);

               // sends/receives data over the socket
               ExternalInterface.addCallback("send", send);
               ExternalInterface.addCallback("receive", receive);

               // gets the number of bytes available on a socket
               ExternalInterface.addCallback(
                  "getBytesAvailable", getBytesAvailable);

               // add a callback for subscribing to socket events
               ExternalInterface.addCallback("subscribe", subscribe);

               // add callbacks for deflate/inflate
               ExternalInterface.addCallback("deflate", deflate);
               ExternalInterface.addCallback("inflate", inflate);

               // add callbacks for local disk storage
               ExternalInterface.addCallback("setItem", setItem);
               ExternalInterface.addCallback("getItem", getItem);
               ExternalInterface.addCallback("removeItem", removeItem);
               ExternalInterface.addCallback("clearItems", clearItems);

               // socket pool is now ready
               ExternalInterface.call("window.forge.socketPool.ready");
            }
            catch(e:Error)
            {
               log("error=" + e.errorID + "," + e.name + "," + e.message);
               throw e;
            }

            log("ready");
         }
      }

      /**
       * A debug logging function.
       *
       * @param obj the string or error to log.
       */
      CONFIG::debugging
      private function log(obj:Object):void
      {
         if(obj is String)
         {
            var str:String = obj as String;
            ExternalInterface.call("console.log", "SocketPool", str);
         }
         else if(obj is Error)
         {
            var e:Error = obj as Error;
            log("error=" + e.errorID + "," + e.name + "," + e.message);
         }
      }

      CONFIG::release
      private function log(obj:Object):void
      {
         // log nothing in release mode
      }

      /**
       * Called by javascript to initialize this SocketPool.
       *
       * @param options:
       *        marshallExceptions: true to pass exceptions to and from
       *           javascript.
       */
      private function init(... args):void
      {
         log("init()");

         // get options from first argument
         var options:Object = args.length > 0 ? args[0] : null;

         // create socket map, set next ID, and create event dispatcher
         mSocketMap = new Object();
         mNextId = 1;
         mEventDispatcher = new EventDispatcher();

         // enable marshalling exceptions if appropriate
         if(options != null &&
            "marshallExceptions" in options &&
            options.marshallExceptions === true)
         {
            try
            {
               // Note: setting marshallExceptions in IE, even inside of a
               // try-catch block will terminate flash. Don't set this on IE.
               ExternalInterface.marshallExceptions = true;
            }
            catch(e:Error)
            {
               log(e);
            }
         }

         log("init() done");
      }

      /**
       * Called by javascript to clean up a SocketPool.
       */
      private function cleanup():void
      {
         log("cleanup()");

         mSocketMap = null;
         mNextId = 1;
         mEventDispatcher = null;

         log("cleanup() done");
      }

      /**
       * Handles events.
       *
       * @param e the event to handle.
       */
      private function handleEvent(e:Event):void
      {
         // dispatch socket event
         var message:String = (e is TextEvent) ? (e as TextEvent).text : null;
         mEventDispatcher.dispatchEvent(
            new SocketEvent(e.type, e.target as PooledSocket, message));
      }

      /**
       * Called by javascript to create a Socket.
       *
       * @return the Socket ID.
       */
      private function create():String
      {
         log("create()");

         // create a Socket
         var id:String = "" + mNextId++;
         var s:PooledSocket = new PooledSocket();
         s.id = id;
         s.addEventListener(Event.CONNECT, handleEvent);
         s.addEventListener(Event.CLOSE, handleEvent);
         s.addEventListener(ProgressEvent.SOCKET_DATA, handleEvent);
         s.addEventListener(IOErrorEvent.IO_ERROR, handleEvent);
         s.addEventListener(SecurityErrorEvent.SECURITY_ERROR, handleEvent);
         mSocketMap[id] = s;

         log("socket " + id + " created");
         log("create() done");

         return id;
      }

      /**
       * Called by javascript to clean up a Socket.
       *
       * @param id the ID of the Socket to clean up.
       */
      private function destroy(id:String):void
      {
         log("destroy(" + id + ")");

         if(id in mSocketMap)
         {
            // remove Socket
            delete mSocketMap[id];
            log("socket " + id + " destroyed");
         }

         log("destroy(" + id + ") done");
      }

      /**
       * Connects the Socket with the given ID to the given host and port,
       * using the given socket policy port.
       *
       * @param id the ID of the Socket.
       * @param host the host to connect to.
       * @param port the port to connect to.
       * @param spPort the security policy port to use, 0 to use a url.
       * @param spUrl the http URL to the policy file to use, null for default.
       */
      private function connect(
         id:String, host:String, port:uint, spPort:uint,
         spUrl:String = null):void
      {
         log("connect(" +
            id + "," + host + "," + port + "," + spPort + "," + spUrl + ")");

         if(id in mSocketMap)
         {
            // get the Socket
            var s:PooledSocket = mSocketMap[id];

            // load socket policy file
            // (permits socket access to backend)
            if(spPort !== 0)
            {
               spUrl = "xmlsocket://" + host + ":" + spPort;
               log("using cross-domain url: " + spUrl);
               Security.loadPolicyFile(spUrl);
            }
            else if(spUrl !== null && typeof(spUrl) !== undefined)
            {
               log("using cross-domain url: " + spUrl);
               Security.loadPolicyFile(spUrl);
            }
            else
            {
               log("not loading any cross-domain url");
            }

            // connect
            s.connect(host, port);
         }
         else
         {
            // no such socket
            log("socket " + id + " does not exist");
         }

         log("connect(" + id + ") done");
      }

      /**
       * Closes the Socket with the given ID.
       *
       * @param id the ID of the Socket.
       */
      private function close(id:String):void
      {
         log("close(" + id + ")");

         if(id in mSocketMap)
         {
            // close the Socket
            var s:PooledSocket = mSocketMap[id];
            if(s.connected)
            {
               s.close();
            }
         }
         else
         {
            // no such socket
            log("socket " + id + " does not exist");
         }

         log("close(" + id + ") done");
      }

      /**
       * Determines if the Socket with the given ID is connected or not.
       *
       * @param id the ID of the Socket.
       *
       * @return true if the socket is connected, false if not.
       */
      private function isConnected(id:String):Boolean
      {
         var rval:Boolean = false;
         log("isConnected(" + id + ")");

         if(id in mSocketMap)
         {
            // check the Socket
            var s:PooledSocket = mSocketMap[id];
            rval = s.connected;
         }
         else
         {
            // no such socket
            log("socket " + id + " does not exist");
         }

         log("isConnected(" + id + ") done");
         return rval;
      }

      /**
       * Writes bytes to a Socket.
       *
       * @param id the ID of the Socket.
       * @param bytes the string of base64-encoded bytes to write.
       *
       * @return true on success, false on failure.
       */
      private function send(id:String, bytes:String):Boolean
      {
         var rval:Boolean = false;
         log("send(" + id + ")");

         if(id in mSocketMap)
         {
         	// write bytes to socket
            var s:PooledSocket = mSocketMap[id];
            try
            {
               var b64:Base64Decoder = new Base64Decoder();
               b64.decode(bytes);
               var b:ByteArray = b64.toByteArray();
               s.writeBytes(b, 0, b.length);
               s.flush();
               rval = true;
            }
            catch(e:IOError)
            {
               log(e);

               // dispatch IO error event
               mEventDispatcher.dispatchEvent(new SocketEvent(
                  IOErrorEvent.IO_ERROR, s, e.message));
               if(s.connected)
               {
                  s.close();
               }
            }
         }
         else
         {
            // no such socket
            log("socket " + id + " does not exist");
         }

         log("send(" + id + ") done");
         return rval;
      }

      /**
       * Receives bytes from a Socket.
       *
       * @param id the ID of the Socket.
       * @param count the maximum number of bytes to receive.
       *
       * @return an object with 'rval' set to the received bytes,
       *         base64-encoded, or set to null on error.
       */
      private function receive(id:String, count:uint):Object
      {
      	 var rval:String = null;
         log("receive(" + id + "," + count + ")");

         if(id in mSocketMap)
         {
         	// only read what is available
            var s:PooledSocket = mSocketMap[id];
            if(count > s.bytesAvailable)
            {
               count = s.bytesAvailable;
            }

            try
            {
               // read bytes from socket
               var b:ByteArray = new ByteArray();
               s.readBytes(b, 0, count);
               b.position = 0;
               var b64:Base64Encoder = new Base64Encoder();
               b64.insertNewLines = false;
               b64.encodeBytes(b, 0, b.length);
               rval = b64.toString();
            }
            catch(e:IOError)
            {
               log(e);

               // dispatch IO error event
               mEventDispatcher.dispatchEvent(new SocketEvent(
                  IOErrorEvent.IO_ERROR, s, e.message));
               if(s.connected)
               {
                  s.close();
               }
            }
         }
         else
         {
            // no such socket
            log("socket " + id + " does not exist");
         }

         log("receive(" + id + "," + count + ") done");
         return {rval: rval};
      }

      /**
       * Gets the number of bytes available from a Socket.
       *
       * @param id the ID of the Socket.
       *
       * @return the number of available bytes.
       */
      private function getBytesAvailable(id:String):uint
      {
         var rval:uint = 0;
         log("getBytesAvailable(" + id + ")");

         if(id in mSocketMap)
         {
            var s:PooledSocket = mSocketMap[id];
            rval = s.bytesAvailable;
         }
         else
         {
            // no such socket
            log("socket " + id + " does not exist");
         }

         log("getBytesAvailable(" + id +") done");
         return rval;
      }

      /**
       * Registers a javascript function as a callback for an event.
       *
       * @param eventType the type of event (socket event types).
       * @param callback the name of the callback function.
       */
      private function subscribe(eventType:String, callback:String):void
      {
         log("subscribe(" + eventType + "," + callback + ")");

         switch(eventType)
         {
            case Event.CONNECT:
            case Event.CLOSE:
            case IOErrorEvent.IO_ERROR:
            case SecurityErrorEvent.SECURITY_ERROR:
            case ProgressEvent.SOCKET_DATA:
            {
               log(eventType + " => " + callback);
               mEventDispatcher.addEventListener(
                  eventType, function(event:SocketEvent):void
               {
                  log("event dispatched: " + eventType);

                  // build event for javascript
                  var e:Object = new Object();
                  e.id = event.socket ? event.socket.id : 0;
                  e.type = eventType;
                  if(event.socket && event.socket.connected)
                  {
                     e.bytesAvailable = event.socket.bytesAvailable;
                  }
                  else
                  {
                     e.bytesAvailable = 0;
                  }
                  if(event.message)
                  {
                     e.message = event.message;
                  }

                  // send event to javascript
                  ExternalInterface.call(callback, e);
               });
               break;
            }
            default:
               throw new ArgumentError(
                  "Could not subscribe to event. " +
                  "Invalid event type specified: " + eventType);
         }

         log("subscribe(" + eventType + "," + callback + ") done");
      }

      /**
       * Deflates the given data.
       *
       * @param data the base64-encoded data to deflate.
       *
       * @return an object with 'rval' set to deflated data, base64-encoded.
       */
      private function deflate(data:String):Object
      {
         log("deflate");

         var b64d:Base64Decoder = new Base64Decoder();
         b64d.decode(data);
         var b:ByteArray = b64d.toByteArray();
         b.compress();
         b.position = 0;
         var b64e:Base64Encoder = new Base64Encoder();
         b64e.insertNewLines = false;
         b64e.encodeBytes(b, 0, b.length);

         log("deflate done");
         return {rval: b64e.toString()};
      }

      /**
       * Inflates the given data.
       *
       * @param data the base64-encoded data to inflate.
       *
       * @return an object with 'rval' set to the inflated data,
       *         base64-encoded, null on error.
       */
      private function inflate(data:String):Object
      {
         log("inflate");
         var rval:Object = {rval: null};
      	
      	 try
      	 {
            var b64d:Base64Decoder = new Base64Decoder();
            b64d.decode(data);
            var b:ByteArray = b64d.toByteArray();
            b.uncompress();
            b.position = 0;
            var b64e:Base64Encoder = new Base64Encoder();
            b64e.insertNewLines = false;
            b64e.encodeBytes(b, 0, b.length);
            rval.rval = b64e.toString();
         }
         catch(e:Error)
         {
         	log(e);
            rval.error = {
                id: e.errorID,
                name: e.name,
                message: e.message
            };
         }

         log("inflate done");
         return rval;
      }

      /**
       * Stores an item with a key and arbitrary base64-encoded data on local
       * disk.
       *
       * @param key the key for the item.
       * @param data the base64-encoded item data.
       * @param storeId the storage ID to use, defaults to "forge.storage".
       *
       * @return an object with rval set to true on success, false on failure
       *         with error included.
       */
      private function setItem(
         key:String, data:String, storeId:String = "forge.storage"):Object
      {
         var rval:Object = {rval: false};
         try
         {
            var store:SharedObject = SharedObject.getLocal(storeId);
            if(!('keys' in store.data))
            {
               store.data.keys = {};
            }
            store.data.keys[key] = data;
            store.flush();
            rval.rval = true;
         }
         catch(e:Error)
         {
         	log(e);
         	rval.error = {
                id: e.errorID,
                name: e.name,
                message: e.message
            };
         }
         return rval;
      }

      /**
       * Gets an item from the local disk.
       *
       * @param key the key for the item.
       * @param storeId the storage ID to use, defaults to "forge.storage".
       *
       * @return an object with rval set to the item data (which may be null),
       *         check for error object if null.
       */
      private function getItem(
         key:String, storeId:String = "forge.storage"):Object
      {
         var rval:Object = {rval: null};
         try
         {
            var store:SharedObject = SharedObject.getLocal(storeId);
            if('keys' in store.data && key in store.data.keys)
            {
               rval.rval = store.data.keys[key];
            }
         }
         catch(e:Error)
         {
         	log(e);
            rval.error = {
                id: e.errorID,
                name: e.name,
                message: e.message
            };
         }
         return rval;
      }

      /**
       * Removes an item from the local disk.
       *
       * @param key the key for the item.
       * @param storeId the storage ID to use, defaults to "forge.storage".
       *
       * @return an object with rval set to true if removed, false if not.
       */
      private function removeItem(
         key:String, storeId:String = "forge.storage"):Object
      {
         var rval:Object = {rval: false};
         try
         {
            var store:SharedObject = SharedObject.getLocal(storeId);
            if('keys' in store.data && key in store.data.keys)
            {
               delete store.data.keys[key];

               // clean up storage entirely if empty
               var empty:Boolean = true;
               for(var prop:String in store.data.keys)
               {
                  empty = false;
                  break;
               }
               if(empty)
               {
                  store.clear();
               }
               rval.rval = true;
            }
         }
         catch(e:Error)
         {
            log(e);
            rval.error = {
                id: e.errorID,
                name: e.name,
                message: e.message
            };
         }
         return rval;
      }

      /**
       * Clears an entire store of all of its items.
       *
       * @param storeId the storage ID to use, defaults to "forge.storage".
       *
       * @return an object with rval set to true if cleared, false if not.
       */
      private function clearItems(storeId:String = "forge.storage"):Object
      {
         var rval:Object = {rval: false};
         try
         {
            var store:SharedObject = SharedObject.getLocal(storeId);
            store.clear();
            rval.rval = true;
         }
         catch(e:Error)
         {
            log(e);
            rval.error = {
                id: e.errorID,
                name: e.name,
                message: e.message
            };
         }
         return rval;
      }
   }
}
