/**
 * Responsible for maintaining a websocket connection with the client software, and exposing functions that can be invoked across that bridge
 *
 * */

"use strict";

let logger = console

class Bridge {
  constructor(options) {
    this.options = options
    this.connecting = false
    this.connected = false
    this.clientType = null
    this.channelId = null
    this.clientClosedConnection = false
    this.connectPromise = null
    this.initialConnectAttempt = true
    this.reconnectAttemptLookup = [2, 2, 2, 4, 8, 15, 30]
    this.reconnectAttempt = 0
    this.reconnectingWaitPeriodStart = null
    this.reconnectDueTime = null
    this.connectTimeout = null
    this.countdownInterval = null
    this.lastDisconnectedReason = null
    this.PROMISE_TIMEOUT_MSEC = 2000;

    // heartbeat fields
    this.heartbeatInterval = null
    this.heartbeatMS = null
    this.connection_expire_time = null;
    this.heartbeatInterval = null
    this.heartbeatAckCheckInterval = null
    this.lastHeartbeatAckTime = null
    this.lastHeartbeatFound = false
    this.heartbeatId = 1;
    this.lastHeartbeatSentTime = null;

    // messaging fields
    this.MESSAGE_ID = 1
    this.unresolvedMessages = {}

  }

  connect() {

    if(this.connecting) {
      logger.error("client comm: connect should never be called if we are already connecting. cancelling.")
      // XXX should return connectPromise, but needs to be tested/vetted
      return;
    }
    if(this.connected) {
      logger.error("client comm: connect should never be called if we are already connected. cancelling.")
      // XXX should return connectPromise, but needs to be tested/vetted
      return;
    }


    this.connectPromise = new P((resolve, reject) => {
      //this.channelId = context.JK.generateUUID(); // create a new channel ID for every websocket connection

      this.connectPromiseResolve = resolve
      this.connectPromiseReject = reject
      let uri = "ws://localhost:54321/TestWebSocketServer"

      logger.debug("client comm: connecting websocket: " + uri);

      this.socket = new window.WebSocket(uri);
      this.socket.onopen = (() => this.onOpen() )
      this.socket.onmessage = ((e) => this.onMessage(e) )
      this.socket.onclose = (() => this.onClose() )
      this.socket.onerror = ((e) => this.onError(e) )
      this.socket.channelId = this.channelId; // so I can uniquely identify this socket later

      this.connectTimeout = setTimeout(() => {
        logger.debug("client commo: connection timeout fired", this)
        this.connectTimeout = null;

        if(this.connectPromise.isPending()) {
          this.close(true);
          this.connectPromise.reject();
        }
      }, 4000);
    })

    return this.connectPromise;
  };

  onMessage (event) {
    console.log("ON MESSAGE", event)
    var obj = JSON.parse(event.data);
    if (obj.event) {
      // event from server
      // TODO triggerHandler...
      logger.debug("client comm: event")
    } else if (obj.msgid) {

      // response from server to a request
      if (obj.msgid in this.unresolvedMessages) {
        logger.debug("client comm: response=", obj)

        var msgInfo = this.unresolvedMessages[obj.msgid];
        if (msgInfo) {
          delete this.unresolvedMessages[obj.msgid];

          // store result from server
          msgInfo.result = obj.result;

          var prom = msgInfo.promise;
          // if promise is pending, call the resolve callback
          // we don't want to parse the result here
          // not sure how we can change the state of the promise at this point

          if(!prom) {
            logger.warn ("no promise for message!", msgInfo)
          }
          else if (prom.promise.isPending()) {
            // TODO should pass obj.result to resolve callback
            prom.resolve(msgInfo);
          }
          else {
            logger.warn("promise is already resolved!", msgInfo)
          }

        }
      }
    }
    else if(obj.heartbeat_interval_sec) {
      // this is a welcome message from server
      this.heartbeatMS = obj.heartbeat_interval_sec * 1000;
      this.connection_expire_time = obj.connection_timeout_sec * 1000;
      // start interval timer
      this.heartbeatInterval = setInterval((() => this.sendHeartbeat()), this.heartbeatMS);

      // client does not send down heartbeats yet
      //this.heartbeatAckCheckInterval = setInterval((() => this.heartbeatAckCheck()), 1000);

      this.lastHeartbeatAckTime = new Date(new Date().getTime() + this.heartbeatMS); // add a little forgiveness to server for initial heartbeat
      // send first heartbeat right away
      this.sendHeartbeat();

      this.heartbeatAck(); // every time we get this message, it acts also as a heartbeat ack
    }
    else {
      logger.warn("client comm: unknown message type", msgInfo)
    }

    //processWSMessage(serverMessage);
  }

  invokeMethod (method, args=[]) {
    let msg_id = this.MESSAGE_ID.toString();
    this.MESSAGE_ID += 1;

    let invoke  = {msgid:msg_id, args: args, method:method}
    logger.debug(`client comm: invoking ${method}`, invoke)

    let promise = this.send(invoke, true)
    //promise.catch((e) => {logger.error("EEE", e)})
    return promise
  }

  // resolve callback gets called when the request message is sent to the
  // server *and* a response message is received from the server,
  // regardless of what is in the response message, parsing of the
  // response from the server is the responsibility of the resolve callback
  getWSPromise(msg, id) {
    logger.debug("client comm: getWSPromise")
    let wrappedPromise = {}
    let prom = new P((resolve, reject) => {

      wrappedPromise.resolve = resolve;
      wrappedPromise.reject = reject;

      var msgInfo = {
        msgId : id,
        promise : wrappedPromise,
        result : undefined
      };

      this.unresolvedMessages[id] = msgInfo;

      try {
        logger.debug("client comm: sending it: " + msg)
        this.socket.send(msg)
      } catch(e) {
        logger.error("unable to send message", e)
        delete this.unresolvedMessages[id];
        reject({reason:'no_send', detail: "client com: unable to send message" + e.message});
      }

    }).cancellable().catch(P.CancellationError, (e) => {
        // Don't swallow it
        throw e;
      });

    wrappedPromise.then = prom.then.bind(prom);
    wrappedPromise.catch = prom.catch.bind(prom);
    wrappedPromise.timeout = prom.timeout.bind(prom);
    // to access isPending(), etc
    wrappedPromise.promise = prom;

    return wrappedPromise.promise;
  }

  send(msg, expectsResponse = false) {

    let wire_format = JSON.stringify(msg)

    //logger.debug(`client comm: sending ${msg}`)

    if(expectsResponse) {
      let id = msg.msgid;

      let requestPromise = this.getWSPromise(wire_format, msg.msgid)
      requestPromise
      .timeout(this.PROMISE_TIMEOUT_MSEC)
      .catch(P.TimeoutError, (e) => {
        logger.error("client comm: Promise timed out! " + e);
        // call reject callback
        // if id is in unresolved message map
        if (id in this.unresolvedMessages) {

          var msgInfo = this.unresolvedMessages[id];

          if (msgInfo) {
            var prom = msgInfo.promise;
            // if promise is pending, call the reject callback
            // not sure how we can change the state of the promise at this point
            if (prom != undefined && prom.promise.isPending()) {
              msgInfo.promise.reject({reason: 'send_timeout', detail: "We did not get a response from the client in a timely fashion"});
            }
          }
          // remove from map
          delete this.unresolvedMessages[id];
        }
      })
      .catch(P.CancellationError, (e) => {
        logger.warn("Promise cancelled! ", e);
        // call reject callback
        // if id is in unresolved message map
        if (id in this.unresolvedMessages) {

          var msgInfo = this.unresolvedMessages[id];

          if (msgInfo) {
            var prom = msgInfo.promise;
            // if promise is pending, call the reject callback
            // not sure how we can change the state of the promise at this point
            if (prom != undefined && prom.promise.isPending()) {
              msgInfo.promise.reject({reason: 'cancelled', detail: "The request was cacelled"});
            }
          }
          // remove from map
          delete this.unresolvedMessages[id];
        }
      })
      .catch((e) => {
        logger.warn("Promise errored! ", e);
        // call reject callback
        // if id is in unresolved message map
        if (id in this.unresolvedMessages) {

          var msgInfo = this.unresolvedMessages[id];

          if (msgInfo) {
            var prom = msgInfo.promise;
            // if promise is pending, call the reject callback
            // not sure how we can change the state of the promise at this point
            if (prom != undefined && prom.promise.isPending()) {
              msgInfo.promise.reject({reason: 'unknown_error', detail: e});
            }
          }
          // remove from map
          delete this.unresolvedMessages[id];
        }
      });

      return requestPromise;
    }
    else {
      this.socket.send(wire_format)
    }
  }

  onError (error) {
    logger.debug("client comm: error", error)
  }

  close (in_error) {
    logger.info("client comm: closing websocket");

    this.clientClosedConnection = true;
    this.socket.close();

    this.closedCleanup(in_error);
  }


  onOpen () {
    logger.debug("client comm: server.onOpen");

    // we should receive LOGIN_ACK very soon.  we already set a timer elsewhere to give 4 seconds to receive it

    this.fullyConnected()
  };

  fullyConnected() {

    this.clearConnectTimeout();

    this.heartbeatStateReset();

    // this has to be after context.jamclient.OnLoggedIn, because it hangs in scenarios
    // where there is no device on startup for the current profile.
    // So, in that case, it's possible that a reconnect loop will attempt, but we *do not want*
    // it to go through unless we've passed through .OnLoggedIn
    this.connected = true;
    this.reconnecting = false;
    this.connecting = false;
    this.initialConnectAttempt = false;

    //this.heartbeatMS = payload.heartbeat_interval * 1000;
    //connection_expire_time = payload.connection_expire_time * 1000;
    //logger.info("loggedIn(): clientId=" + app.clientId + " heartbeat=" + payload.heartbeat_interval + "s expire_time=" + payload.connection_expire_time + 's');
    //heartbeatInterval = context.setInterval(_heartbeat, heartbeatMS);
    //heartbeatAckCheckInterval = context.setInterval(_heartbeatAckCheck, 1000);
    //lastHeartbeatAckTime = new Date(new Date().getTime() + heartbeatMS); // add a little forgiveness to server for initial heartbeat
    this.connectPromiseResolve();
    //$self.triggerHandler(EVENTS.CONNECTION_UP)
  }

  // onClose is called if either client or server closes connection
  onClose () {
    logger.info("client comm: Socket to server closed.");

    var disconnectedSocket = this;

    if(disconnectedSocket.channelId != this.socket.channelId) {
      logger.debug(" client comm: ignoring disconnect for non-current socket. current=" + this.socket.channelId + ", disc=" + disconnectedSocket.channelId)
      return;
    }

    if (this.connectPromise.isPending()) {
      this.connectPromise.reject();
    }

    this.closedCleanup(true);
  };

  // handles logic if the websocket connection closes, and if it was in error then also prompt for    reconnect
  closedCleanup(in_error) {

    if(this.connected) {
      //$self.triggerHandler(EVENTS.CONNECTION_DOWN);
    }

    this.connected = false;
    this.connecting = false;

    // stop future heartbeats
    if (this.heartbeatInterval != null) {
      clearInterval(this.heartbeatInterval);
      this.heartbeatInterval = null;
    }

    // stop checking for heartbeat acks
    if (this.heartbeatAckCheckInterval != null) {
      clearTimeout(this.heartbeatAckCheckInterval);
      this.heartbeatAckCheckInterval = null;
    }

    this.clearConnectTimeout();

    // noReconnect is a global to suppress reconnect behavior, so check it first

    // we don't show any reconnect dialog on the initial connect; so we have this one-time flag
    // to cause reconnects in the case that the websocket is down on the initially connect
    if(this.noReconnect) {
      //renderLoginRequired();
    }
    else if ((this.initialConnectAttempt || !this.reconnecting)) {
      this.reconnecting = true;
      this.initialConnectAttempt = false;

      this.initiateReconnect(in_error);
    }
  }

  clearConnectTimeout() {
    if (this.connectTimeout) {
      clearTimeout(this.connectTimeout)
      this.connectTimeout = null
    }
  }

  initiateReconnect(in_error) {

    if (in_error) {
      this.reconnectAttempt = 0;
      this.beginReconnectPeriod();
    }
  }

  beginReconnectPeriod() {

    this.reconnectingWaitPeriodStart = new Date().getTime();
    this.reconnectDueTime = this.reconnectingWaitPeriodStart + this.reconnectDelaySecs() * 1000;

    // update count down timer periodically
    this.countdownInterval = setInterval(() => {
      let now = new Date().getTime();
      if (now > this.reconnectDueTime) {
        this.clearReconnectTimers();
        this.attemptReconnect();
      }
      else {
        let secondsUntilReconnect = Math.ceil((this.reconnectDueTime - now) / 1000);
        logger.debug("client comm: until reconnect :" + this.secondsUntilReconnect)
        //$currentDisplay.find('.reconnect-countdown').html(formatDelaySecs(secondsUntilReconnect));
      }
    }, 333);
  }

  attemptReconnect() {

    if(this.connecting) {
      logger.warn("client comm: attemptReconnect called when already connecting");
      return;
    }

    if(this.connected) {
      logger.warn("client comm: attemptReconnect called when already connected");
      return;
    }

    let start = new Date().getTime();

    logger.debug("client comm: Attempting to reconnect...")

    this.guardAgainstRapidTransition(start, this.internetUp);
  }

  internetUp() {
    let start = new Date().getTime();
    this.connect()
      .then(() => {
        this.guardAgainstRapidTransition(start, this.finishReconnect);
      })
      .catch(() => {
        this.guardAgainstRapidTransition(start, this.closedOnReconnectAttempt);
      });
  }

  finishReconnect() {

    logger.debug("client comm: websocket reconnected")
    if(!this.clientClosedConnection) {
      this.lastDisconnectedReason = 'WEBSOCKET_CLOSED_REMOTELY'
      this.clientClosedConnection = false;
    }
    else if(!this.lastDisconnectedReason) {
      // let's have at least some sort of type, however generci
      this.lastDisconnectedReason = 'WEBSOCKET_CLOSED_LOCALLY'
    }

    /**
    if ($currentDisplay.is('.no-websocket-connection')) {
      // this path is the 'not in session path'; so there is nothing else to do
      $currentDisplay.removeClass('active');

      // TODO: tell certain elements that we've reconnected
    }
    else {
      window.location.reload();
    }*/

  }

  // websocket couldn't connect.  let's try again soon
  closedOnReconnectAttempt() {
    this.failedReconnect();
  }

  failedReconnect() {
    this.reconnectAttempt += 1;
    this.renderCouldNotReconnect();
    this.beginReconnectPeriod();
  }

  renderCouldNotReconnect() {
    return renderDisconnected();
  }

  renderDisconnected() {
    //logger.debug("")
  }

  guardAgainstRapidTransition(start, nextStep) {
    var now = new Date().getTime();

    if ((now - start) < 1500) {
      setTimeout(() => {
        nextStep();
      }, 1500 - (now - start))
    }
    else {
      nextStep();
    }
  }


  clearReconnectTimers() {
    if (this.countdownInterval) {
      clearInterval(this.countdownInterval);
      this.countdownInterval = null;
    }
  }


  reconnectDelaySecs() {
    if (this.reconnectAttempt > this.reconnectAttemptLookup.length - 1) {
      return this.reconnectAttemptLookup[this.reconnectAttemptLookup.length - 1];
    }
    else {
      return this.reconnectAttemptLookup[this.reconnectAttempt];
    }
  }

  ////////////////////
  //// HEARTBEAT /////
  ////////////////////
  heartbeatAck() {
    logger.debug("client comm: heartbeat ack")
    this.lastHeartbeatAckTime = new Date()
  }

  heartbeatAckCheck() {

    // if we've seen an ack to the latest heartbeat, don't bother with checking again
    // this makes us resilient to front-end hangs
    if (this.lastHeartbeatFound) {
      return;
    }

    // check if the server is still sending heartbeat acks back down
    // this logic equates to 'if we have not received a heartbeat within heartbeatMissedMS, then get upset
    if (new Date().getTime() - this.lastHeartbeatAckTime.getTime() > this.connection_expire_time) {
      logger.error("client comm: no heartbeat ack received from server after ", this.connection_expire_time, " seconds . giving up on socket connection");
      this.lastDisconnectedReason = 'NO_HEARTBEAT_ACK';
      this.close(true);
    }
    else {
      this.lastHeartbeatFound = true;
    }
  }

  heartbeatStateReset() {
    this.lastHeartbeatSentTime = null;
    this.lastHeartbeatAckTime = null;
    this.lastHeartbeatFound = false;
  }

  sendHeartbeat() {
      let msg = { heartbeat: this.heartbeatId.toString() }
      this.heartbeatId += 1;

      // for debugging purposes, see if the last time we've sent a heartbeat is way off (500ms) of the target interval
      var now = new Date();


      if(this.lastHeartbeatSentTime) {
        var drift = new Date().getTime() - this.lastHeartbeatSentTime.getTime() - this.heartbeatMS;
        if (drift > 500) {
          logger.warn("client comm: significant drift between heartbeats: " + drift + 'ms beyond target interval')
        }
      }
      this.lastHeartbeatSentTime = now;
      this.send(msg);
      this.lastHeartbeatFound = false;
  }

  async meh () {
    logger.debug("meh ")
    this.IsMyNetworkWireless()
    logger.debug("lurp?")
  }
  async IsMyNetworkWireless() {
    logger.debug("IsMyNetworkWireless invoking...")
    let response = await this.invokeMethod('IsMyNetworkWireless()')
    logger.debug("IsMyNetworkWireless invoked", response)
    return response
  }
}

/**
setTimeout(function(){
  let bridge = new Bridge({})
  bridge.connect().then(function(){
    console.log("CONNECTED!!")

    //bridge.meh()
    bridge.IsMyNetworkWireless()
    console.log("so fast")

  })
}, 500)

*/
