# This is a little wrapper around browserchannels which exposes something thats compatible
# with the websocket API. It also supports automatic reconnecting, and some other goodies.
#
# You can use it just like websockets:
#
#     var socket = new BCSocket '/foo'
#     socket.onopen = ->
#       socket.send 'hi mum!'
#     socket.onmessage = (message) ->
#       console.log 'got message', message
# 
# ... etc. See here for specs:
# http://dev.w3.org/html5/websockets/
#
# I've also added:
#
# - You can reconnect a disconnected socket using .open().
# - .send() transparently works with JSON objects.
# - .sendMap() works as a lower level sending mechanism.
# - The second argument can be an options argument. Valid options:
#   - **appVersion**: Your application's protocol version. This is passed to the server-side
#     browserchannel code, in through your session handler as `session.appVersion`
#   - **prev**: The previous BCSocket object, if one exists. When the socket is established,
#     the previous bcsocket session will be disconnected as we reconnect.
#   - **reconnect**: Tell the socket to automatically reconnect when its been disconnected.
#   - **failFast**: Make the socket report errors immediately, rather than trying a few times
#     first.
#   - **crossDomainXhr**: Set to true to enable the cross-origin credential
#     flags in XHR requests. The server must send the
#     Access-Control-Allow-Credentials header and can't use wildcard access
#     control hostnames. This is needed if you're using host prefixes. See:
#       http://www.html5rocks.com/en/tutorials/cors/#toc-withcredentials
#   - **extraParams**: Extra query parameters to be sent with requests. If
#     present, this should be a map of query parameter / value pairs. Note that
#     these parameters are resent with every request, so you might want to think
#     twice before putting a lot of stuff in here.
#   - **extraHeaders**: Extra headers to add to requests. Be advised that not
#     all headers are allowed by the XHR spec. Headers from NodeJS clients are
#     unrestricted.

goog.provide 'bc.BCSocket'

goog.require 'goog.net.BrowserChannel'
goog.require 'goog.net.BrowserChannel.Handler'
goog.require 'goog.net.BrowserChannel.Error'
goog.require 'goog.net.BrowserChannel.State'
goog.require 'goog.string'

# Uncomment for extra debugging information in the console. This currently breaks nodejs support
# unfortunately.
#
# goog.require 'goog.debug.Console' 
# goog.debug.Console.instance = new goog.debug.Console()
# goog.debug.Console.instance.setCapturing(true

# Closure uses numerical error codes. We'll translate them into strings for the user.
errorMessages = {}
errorMessages[goog.net.BrowserChannel.Error.OK] = 'Ok'
errorMessages[goog.net.BrowserChannel.Error.LOGGED_OUT] = 'User is logging out'
errorMessages[goog.net.BrowserChannel.Error.UNKNOWN_SESSION_ID] = 'Unknown session ID'
errorMessages[goog.net.BrowserChannel.Error.STOP] = 'Stopped by server'

# All of these error messages basically boil down to "Something went wrong - try again". I can't
# imagine using different logic on the client based on the error here - just keep reconnecting.

# The client's internet is down (ping to google failed)
errorMessages[goog.net.BrowserChannel.Error.NETWORK] = 'General network error'
# The server could not be contacted
errorMessages[goog.net.BrowserChannel.Error.REQUEST_FAILED] = 'Request failed'

# This error happens when the client can't connect to the special test domain. In my experience,
# this error happens normally sometimes as well - if one particular connection doesn't
# make it through during the channel test. This will never happen with node-browserchannel anyway
# because we don't support the network admin blocking channel.
errorMessages[goog.net.BrowserChannel.Error.BLOCKED] = 'Blocked by a network administrator'

# We got an invalid response from the server
errorMessages[goog.net.BrowserChannel.Error.NO_DATA] = 'No data from server'
errorMessages[goog.net.BrowserChannel.Error.BAD_DATA] = 'Got bad data from the server'
errorMessages[goog.net.BrowserChannel.Error.BAD_RESPONSE] = 'Got a bad response from the server'

`/** @constructor */`
BCSocket = (url, options) ->
  return new BCSocket(url, options) unless this instanceof BCSocket

  self = this

  # Url can be relative or absolute. (Though an absolute URL in the browser will
  # have to match same origin policy)
  url ||= 'channel'

  # Websocket urls are specified as ws:// or wss://. Replace the leading ws with
  # http.
  url.replace /^ws/, 'http' if url.match /:\/\//

  options ||= {}

  # Using websockets you can specify an array of protocol versions or a protocol
  # version string. All that stuff is ignored.
  options = {} if goog.isArray options or typeof options is 'string'

  reconnectTime = options['reconnectTime'] or 3000

  # Extra headers. Not all headers can be set, and the headers that can be set
  # changes depending on whether we're connecting from nodejs or from the
  # browser.
  extraHeaders = options['extraHeaders'] or null

  # Extra GET parameters
  extraParams = options['extraParams'] or null

  # Generate a session affinity token to send with all requests.
  # For use with a load balancer that parses GET variables.
  unless options['affinity'] is null
    extraParams ||= {}
    options['affinityParam'] ||= 'a'

    @['affinity'] = options['affinity'] || goog.string.getRandomString()
    extraParams[options['affinityParam']] = @['affinity']

  # The channel starts CLOSED. When connect() is called, the channel moves into
  # the CONNECTING state. If it connects, it moves to OPEN. If an error occurs
  # (or an error occurs while the connection is connected), the socket moves to
  # 'CLOSED' again.
  #
  # At any time, you can call close(), which disconnects the socket.

  setState = (state) -> # This is convenient for logging state changes, and increases compression.
    #console?.log "state from #{self.readyState} to #{state}"
    self.readyState = self['readyState'] = state

  setState @CLOSED

  # The current browserchannel session we're connected through.
  session = null

  # When we reconnect, we'll pass the SID and AID from the previous time we
  # successfully connected. This ghosts the previous session if the server
  # thinks its still around. If you aren't using BCSocket's reconnection
  # support, pass the old BCSocket object in to options.prev.
  #
  # It might be more correct here to use lastSession instead (which would mean
  # we would only use a session after it has been opened).
  lastSession = options['prev']?.session

  # Closure has an annoyingly complicated logging system which by default will
  # silently capture & discard any errors thrown in callbacks. I could enable
  # the logging infrastructure (above), but I prefer to just log errors as
  # needed.
  #
  # The callback takes three arguments because thats the max any event needs to
  # pass into its callback.
  fireCallback = (name, shouldThrow, a, b, c) ->
    try
      self[name]? a, b, c
    catch e
      console?.error e.stack
      throw e

  # A handler is used to receive events back out of the session.
  handler = new goog.net.BrowserChannel.Handler()

  handler.channelOpened = (channel) ->
    lastSession = session
    setState BCSocket.OPEN
    fireCallback 'onopen', true

  # If there's an error, the handler's channelError() method is called right
  # before channelClosed(). We'll cache the error so a 'disconnect' handler
  # knows the disconnect reason.
  lastErrorCode = null

  # This is called when the session has the final error explaining why its
  # closing. It is called only once, just before channelClosed(). It is not
  # called if the session is manually disconnected.
  handler.channelError = (channel, errCode) ->
    message = errorMessages[errCode]
    #console?.error "channelError #{errCode} : #{message} in state #{self.readyState}"
    lastErrorCode = errCode

    # If your network connection is down, you'll get General Network Errors
    # passing through here even when you're not connected.
    setState BCSocket.CLOSING unless self.readyState is BCSocket.CLOSED

    # I'm not 100% sure what websockets do if there's an error like this. I'm
    # going to assume it has the same behaviour as browserchannel - that is,
    # onclose() is always called if a connection closes, and onerror is called
    # whenever an error occurs.
 
    # If fireCallback throws, channelClosed (below) never gets called, which in
    # turn causes the connection to never reconnect. We'll eat the exceptions so
    # that doesn't happen.
    fireCallback 'onerror', false, message, errCode
  
  # When HTTP connection with session goes down because of network errors,
  # handler uses this URL to make and HTTP request. If it succeeds, handler
  # understands, that network is ok, it's just backend went down. 
  # It if does not succeed, user has probably disconnected from network at all.
  # the URL should point to a tiny image.
  handler.getNetworkTestImageUri = (obj) ->
     return options['testImageUri']

  reconnectTimer = null

  # This will be called whenever the client disconnects or fails to connect for
  # any reason. When we fail to connect, I'll also fire 'onclose' (even though
  # onopen is never called!) for two reasons:
  #
  # - The state machine goes from CLOSED -> CONNECTING -> CLOSING -> CLOSED, so
  #   technically we did enter the 'close' state.
  # - Thats what websockets do (onclose() is called on a websocket if it fails
  #   to connect).
  handler.channelClosed = (channel, pendingMaps, undeliveredMaps) ->
    #console.trace 'channelClosed ' + self.readyState

    # Hm.
    #
    # I'm not sure what to do with this potentially-undelivered data. I think
    # I'll toss it to the emitter and let that deal with it.
    #
    # I'd rather call a callback on send(), like the server does. But I can't,
    # because browserchannel's API isn't rich enough.

    # Should handle server stop
    return if self.readyState is BCSocket.CLOSED

    # And once channelClosed is called, we won't get any more events from the
    # session. So things like send() should throw exceptions.
    session = null

    message = if lastErrorCode then errorMessages[lastErrorCode] else 'Closed'

    setState BCSocket.CLOSED

    # If the error message is STOP, we won't reconnect. That means the server
    # has explicitly requested the client give up trying to reconnect due to
    # some error.
    #
    # The error code will be 'OK' if close() was called on the client.
    if options['reconnect'] and lastErrorCode not in [goog.net.BrowserChannel.Error.STOP, goog.net.BrowserChannel.Error.OK]
      #console.warn 'rc'
      # If the session ID is unknown, that means the session has timed out. We
      # can reconnect immediately.
      time = if lastErrorCode is goog.net.BrowserChannel.Error.UNKNOWN_SESSION_ID then 0 else reconnectTime

      clearTimeout reconnectTimer
      reconnectTimer = setTimeout reconnect, time

    # This happens after the reconnect timer is set so the callback can call
    # close() to cancel reconnection.
    fireCallback 'onclose', false, message, pendingMaps, undeliveredMaps

    # make sure we don't reuse an old error message later
    lastErrorCode = null

  # Messages from the server are passed directly.
  handler.channelHandleArray = (channel, data) ->
    # Websocket onmessage handlers accept a MessageEvent object, which contains
    # all sorts of other stuff unrelated to the message itself.
    message =
      type: 'message'
      data: data
      
    fireCallback 'onmessage', true, message

  # This reconnects if the current session is null.
  reconnect = ->
    # It should be impossible for this function to be reentrant - the only
    # places it can be called from are open() below and from the setTimeout
    # above (which is disabled when reconnect is called). I'll just check it
    # anyway though, because its sort of important.
    throw new Error 'Reconnect() called from invalid state' if session

    setState BCSocket.CONNECTING
    fireCallback 'onconnecting', true

    clearTimeout reconnectTimer

    self.session = session = new goog.net.BrowserChannel options['appVersion'], lastSession?.getFirstTestResults()
    session.setSupportsCrossDomainXhrs true if options['crossDomainXhr']
    session.setHandler handler
    session.setExtraHeaders extraHeaders if extraHeaders
    lastErrorCode = null

    session.setFailFast yes if options['failFast']

    # Only needed for debugging..
    #session.setChannelDebug(new goog.net.ChannelDebug())

    session.connect "#{url}/test", "#{url}/bind", extraParams,
      lastSession?.getSessionId(), lastSession?.getLastArrayId()

  # This isn't in the normal websocket interface. It reopens a previously closed
  # websocket connection by reconnecting.
  @['open'] = ->
    # If the session is already open, you should call close() first.
    throw new Error 'Already open' unless self.readyState is self.CLOSED
    reconnect()

  # This closes the connection and stops it from reconnecting.
  @['close'] = ->
    clearTimeout reconnectTimer

    # I'm abusing lastErrorCode here so in the channelClosed handler I can make
    # sure we don't try to reconnect.
    lastErrorCode = goog.net.BrowserChannel.Error.OK

    return if self.readyState is BCSocket.CLOSED

    setState BCSocket.CLOSING

    # In theory, we don't transition to the CLOSED state until the server has
    # received the disconnect message. But in practice, disconnect() results in
    # channelClosed() being called immediately. The server is still notified,
    # but only really as an afterthought.
    session.disconnect()

  # TODO: Make @send to take a callback which is called when the message is
  # either confirmed received or failed. The closure library has recently added
  # a mechanism to do this.
  #
  # Note that you *can* send messages while the channel is connecting. Thats a
  # *GOOD IDEA* - any messages sent then should be sent with the initial
  # payload.
  @['sendMap'] = sendMap = (map) ->
    # This is the raw way to send messages. We'll silently consume messages sent
    # after the connection closes. This is the logic all consumers of the API
    # end up implementing anyway.
    if self.readyState in [BCSocket.CLOSING, BCSocket.CLOSED]
      #console?.warn 'Cannot send to a closed connection'
      return

    session.sendMap map

  # This sends a map of {JSON:"..."} or {_S:"..."}. It is interpreted as a native message by the server.
  @['send'] = (message) ->
    if typeof message is 'string'
      sendMap '_S': message
    else
      sendMap 'JSON': goog.json.serialize message

  # Websocket connections are automatically opened.
  reconnect()

  return

# Flag to tell clients they can cheat and send while the session is being
# established. Its good practice with browserchannel to send messages while
# the session is being set up - its faster for your users. But websockets
# don't support that. We could pretend that connections open immediately (for
# api compatibility), but if people bound UI to the connection state, it would
# look wrong.
# 
# If you want a fast start, look for this flag.
BCSocket.prototype['canSendWhileConnecting'] = BCSocket['canSendWhileConnecting'] = true

# Flag to indicate native JSON support. The advantage of using browserchannel's
# own JSON support is that it uses the closure library's JSON.stringify /
# JSON.parse shims. These shims support old browsers.
BCSocket.prototype['canSendJSON'] = BCSocket['canSendJSON'] = true

BCSocket.prototype['CONNECTING'] = BCSocket['CONNECTING'] = BCSocket.CONNECTING = 0
BCSocket.prototype['OPEN'] = BCSocket['OPEN'] = BCSocket.OPEN = 1
BCSocket.prototype['CLOSING'] = BCSocket['CLOSING'] = BCSocket.CLOSING = 2
BCSocket.prototype['CLOSED'] = BCSocket['CLOSED'] = BCSocket.CLOSED = 3

(exports ? window)['BCSocket'] = BCSocket
