<?xml version="1.0" ?>
<diagram author="Joeri Exelmans" name="client">
  <description>Statechart model implementing much of the 'client' part of our protocol for synchronous collaboration sessions (joining, leaving, auto-reconnecting, broadcasting and receiving operations, and heartbeats to monitor connection health).</description>
  <top></top>
  <inport name="in"/>
  <outport name="out"/>
  <class name="Main" default="true">
    <relationships>
      <association name="socket" class="Socket" min="1" max="1"/>
    </relationships>
    <inport name="ack"/>
    <inport name="socket"/>
    <constructor>
      <parameter name="uiState"/>
      <parameter name="myId"/>
      <parameter name="getMyName"/>

      <body>
        this.uiState = uiState;
        this.myId = myId;
        this.getMyName = getMyName;

        this.connected = false;
      </body>
    </constructor>
    <scxml
      big_step_maximality="take_many"
      internal_event_lifeline="next_combo_step">
      <parallel id="p">

        <state id="response_handler">
          <onentry>
            <script>
              this.reqHandlers = new Map();
              this.reqCounter = 0;
              this.sendReq = (obj, handler) => {
                const reqId = this.reqCounter++;
                const req = {
                  reqId,
                  ...obj,
                };
                if (handler !== undefined) {
                  this.reqHandlers.set(reqId, handler);
                }
                console.log("Sending req", req);
                this.addEvent(new Event("send", null, [req]));

                // allow to cancel the request handler
                return () => {
                  this.reqHandlers.delete(reqId);
                };
              }
            </script>
          </onentry>
          <state id="default">
            <transition event="received" target=".">
              <parameter name="parsed"/>
              <script>
                if (parsed.type === 'ack' &amp;&amp; parsed.reqId !== undefined) {
                  const handler = this.reqHandlers.get(parsed.reqId);
                  if (handler) {
                    handler(parsed);
                  }
                }
              </script>
            </transition>
            <transition event="disconnected" target=".">
              <script>
                // no way we will get a response for our pending requests
                this.reqHandlers.clear();
                this.reqCounter = 0;
              </script>
            </transition>
          </state>
        </state>

        <state id="mode" initial="uninitialized">
          <state id="uninitialized">
            <transition event="init_offline" port="in" target="../async_disconnected"/>
            <transition event="init_join" port="in" target="../can_leave/session_set/rejoin_when_online">
              <parameter name="sessionId"/>
              <script>
                this.sessionId = sessionId;
              </script>
            </transition>
          </state>

          <state id="async_disconnected">
            <onentry><script>
              this.uiState.setOfflineDisconnected();
            </script></onentry>
            <transition event="connected" target="../async_connected"/>
          </state><!-- async_disconnected -->

          <state id="async_connected">
            <onentry><script>
              this.uiState.setOfflineConnected();
            </script></onentry>

            <transition event="disconnected" target="../async_disconnected"/>

            <transition event="join" port="in" target="../can_leave/session_set/waiting_for_join_ack">
              <parameter name="sessionId"/>
              <script> this.sessionId = sessionId; </script>
            </transition>

            <transition event="new_share" port="in" target="../can_leave/waiting_for_new_share_ack">
              <parameter name="ops"/>
              <script>
                // console.log("new share event, ops=", ops);
                this.ops = ops;
              </script>
            </transition>
          </state><!-- async_connected -->

          <state id="can_leave" initial="session_set">

            <transition event="leave" cond='this.connected' port="in" target="../async_connected"/>
            <transition event="leave" cond='!this.connected' port="in" target="../async_disconnected"/>

            <onexit><script>
              this.sendReq({type:"leave"});
            </script></onexit>

            <state id="waiting_for_new_share_ack">
              <onentry><script>
                this.uiState.setCreatingShare();
                this.deleteNewShareHandler = this.sendReq(
                  {type: "new_share", ops: this.ops},
                  res => {
                    // called during transition (as action code), so no need to 'wake up' the controller like we do with 'addInput':
                    this.addEvent(new Event("ack_new_share", "ack", [res.sessionId]));
                  });
              </script></onentry>
              <onexit><script>
                this.deleteNewShareHandler();
              </script></onexit>
              <transition event="disconnected" target="../reshare_when_online"/>
              <transition event="ack_new_share" port="ack" target="../session_set/joined">
                <parameter name="sessionId"/>
                <script>
                  this.sessionId = sessionId;
                </script>
                <raise event="ack_new_share" port="out" scope="output">
                  <parameter expr="sessionId"/>
                </raise>
              </transition>
            </state><!-- waiting_for_new_share_ack -->

            <state id="reshare_when_online">
              <onentry><script>
                this.uiState.setReconnecting();
              </script></onentry>
              <transition event="connected" target="../waiting_for_new_share_ack"/>
            </state>

            <state id="session_set" initial="waiting_for_join_ack">
              <onentry><script>
                if (!this.sessionId) {
                  throw new Error("DEBUG: no session id");
                }
                this.uiState.setSession(this.sessionId);
              </script></onentry>
              <onexit> <script>
                this.uiState.unsetSession();
              </script> </onexit>

               <state id="waiting_for_join_ack">
                  <onentry><script>
                    this.uiState.setJoining();

                    this.deleteJoinHandler = this.sendReq(
                      {type: "join", sessionId: this.sessionId},
                      res => {
                        // called during transition (as action code), so no need to 'wake up' the controller like we do with 'addInput':
                        this.addEvent(new Event("ack_join", "ack", [res.ops]));
                      });
                  </script></onentry>
                  <onexit><script>
                    this.deleteJoinHandler();
                  </script></onexit>
                  <transition event="ack_join" port="ack" target="../joined">
                    <parameter name="ops"/>
                    <raise event="ack_join" port="out" scope="output">
                      <parameter expr="ops"/>
                    </raise>
                  </transition>
                  <transition event="disconnected" target="../rejoin_when_online"/>
                </state><!-- waiting_for_join_ack -->

                <parallel id="joined">
                  <state id="region_sendreceive">
                    <state id="default">
                      <onentry>
                        <script>
                          this.uiState.setOnline();
                        </script>
                      </onentry>
                      <transition event="received" cond="parsed.type === 'pub_edit'" target=".">
                        <parameter name="parsed"/>
                        <script>
                          if (parsed.sessionId !== this.sessionId) {
                            throw new Error("Unexpected: received edit for another session:" +  parsed.sessionId);
                          }
                        </script>
                        <raise event="received_op" port="out" scope="output">
                          <parameter expr="parsed.op"/>
                        </raise>
                      </transition>
                      <transition event="new_edit" port="in" target=".">
                        <parameter name="serialized"/>
                        <script>
                          this.sendReq(
                            {type:"new_edit", sessionId: this.sessionId, op: serialized},
                            res => {
                              // this.unacknowledged.delete(serialized.id);
                            });
                          // this.unacknowledged.set(serialized.id, serialized);
                        </script>
                      </transition>
                      <transition event="request_lock" port="in" target=".">
                        <parameter name="cellIds"/>
                        <parameter name="acquiredCallback"/>
                        <parameter name="deniedCallback"/>
                        <script>
                          this.sendReq(
                            {type:"request_lock", sessionId: this.sessionId, cellIds},
                            res => {
                              if (res.success) {
                                acquiredCallback();
                              } else {
                                deniedCallback();
                              }
                            });
                        </script>
                      </transition>
                      <transition event="release_lock" port="in" target=".">
                        <parameter name="cellIds"/>
                        <script>
                          this.sendReq(
                            {type:"release_lock", sessionId: this.sessionId, cellIds},
                            res => {},
                          );
                        </script>
                      </transition>
                      <transition event="received" cond="parsed.type === 'broadcast'" target=".">
                        <parameter name="parsed"/>
                        <raise event="broadcast" port="out" scope="output">
                          <parameter expr="parsed.sessionId"/>
                          <parameter expr="parsed.msg"/>
                        </raise>
                      </transition>
                      <transition event="broadcast_selection" target=".">
                        <parameter name="selectedIds"/>
                        <script>
                          if (this.cursorState !== null) {
                            this.addEvent(new Event("send", null, [{
                              type:"broadcast",
                              msg: {
                                type:"update_selection",
                                userId: this.myId,
                                selectedIds,
                                ...this.cursorState,
                              },
                            }]));
                          }
                        </script>
                      </transition>
                    </state><!-- default -->
                  </state><!-- region_sendreceive -->
                  <state id="region_cursor" initial="nodelay">
                    <state id="nodelay">
                      <transition event="broadcast_cursor" port="in" target="../delay">
                        <parameter name="x"/>
                        <parameter name="y"/>
                        <raise event="send">
                          <parameter expr='{type:"broadcast", msg: {type:"update_cursor", name: this.getMyName(), x, y, userId: this.myId}}'/>
                        </raise>
                      </transition>
                    </state><!-- nodelay -->
                    <state id="delay">
                      <onentry><script> this.cursorState = null; </script></onentry>
                      <state id="inner">
                        <transition event="broadcast_cursor" port="in" target=".">
                          <parameter name="x"/>
                          <parameter name="y"/>
                          <script>
                            this.cursorState = {x,y};
                          </script>
                        </transition>
                      </state><!-- inner -->
                      <transition after="0.1" target="../nodelay">
                        <script>
                          if (this.cursorState !== null) {
                            this.addEvent(new Event("send", null, [{
                              type:"broadcast",
                              msg: {
                                type:"update_cursor",
                                userId: this.myId,
                                name: this.getMyName(),
                                ...this.cursorState,
                              },
                            }]));
                          }
                        </script>
                      </transition>
                    </state><!-- delay -->
                  </state><!-- region_cursor -->

                  <transition event="disconnected" target="../rejoin_when_online"/>
                </parallel><!-- joined -->
                <state id="rejoin_when_online">
                  <onentry><script>
                    this.uiState.setReconnecting();
                  </script></onentry>
                  <!-- Re-join when connection is restored -->
                  <transition event="connected" target="../waiting_for_join_ack"/>
                </state><!-- rejoin when online -->
              </state><!-- session_set -->
          </state><!-- can_leave -->

        </state><!-- mode -->

        <state id="socket_region" initial="disconnected">
          <state id="disconnected">
            <transition event="connect" port="in" target="../connecting_or_connected">
              <parameter name="addr"/>
              <script>
                console.log("received connect", addr)
                this.addr = addr;
              </script>
            </transition>
          </state>

          <state id="connecting_or_connected" initial="connecting">
            <!-- Within this state, we do our best to establish a connection, and also attempt to re-establish a connection if an error occurs or if the server closes its end. -->

            <transition event="disconnect" port="in" target="../disconnected">
              <script>
                this.socket.close();
              </script>
            </transition>
            <state id="connecting">
              <onentry>
                <script>
                  console.log("connecting...");

                  this.socket = new WebSocket(this.addr);

                  // Translate socket events to statechart events:

                  this.socket.onopen = event => {
                    this.controller.addInput("open", this.inports["socket"], [], this.controller.wallclockToSimtime());
                  };

                  this.socket.onmessage = event => {
                    let parsed;
                    try {
                      parsed = JSON.parse(event.data);
                    } catch (e) {
                      console.log("received unparsable message", e);
                      return;
                    }

                    this.controller.addInput("message", this.inports["socket"], [parsed], this.controller.wallclockToSimtime());
                  };

                  this.socket.onerror = event => {
                    // From what I see, this event only happens when the socket could not connect.
                    // An 'error' happening after the connection is established, will be indicated by a close event.
                    this.controller.addInput("error", this.inports["socket"], [], this.controller.wallclockToSimtime());
                  };

                  this.socket.onclose = event => {
                    this.controller.addInput("close", this.inports["socket"], [], this.controller.wallclockToSimtime());
                  };
                </script>
              </onentry>
              <transition event="open" port="socket" target="../connected"/>
              <transition event="error" port="socket" target="../wait_reconnect">
                <!-- Error event - emitted when connection could not be established  -->
                <raise event="error"/>
              </transition>
            </state>

            <parallel id="connected">
              <onentry>
                <raise event="connected"/>
                <script> this.connected = true; </script>
              </onentry>
              <onexit>
                <raise event="disconnected"/>
                <script> this.connected = false; </script>
              </onexit>

              <!-- Server closed its end -->
              <transition event="close" port="socket" target="../wait_reconnect"/>

              <state id="send_receive_region">
                <state id="ready">
                  <transition event="send" target=".">
                    <parameter name="json"/>
                    <script>
                      this.socket.send(JSON.stringify(json));
                    </script>
                  </transition>
                  <transition event="message" port="socket" target="." cond="parsed.type !== 'pong'">
                    <parameter name="parsed"/>
                    <raise event="received">
                      <parameter expr="parsed"/>
                    </raise>
                  </transition>
                </state>
              </state>

              <state id="connection_monitor_region" initial="all_good">
                <parallel id="all_good">
                  <state id="send_pings">
                    <state id="waiting">
                      <!-- reset ping timer each time we send anything -->
                      <transition event="send" port="in" target="."/>
                      <!-- when not having sent anything for 1s, send a ping to let server know we're still alive -->
                      <transition after="1" target=".">
                        <script>
                          this.socket.send(JSON.stringify({type:"ping"}));
                        </script>
                      </transition>
                    </state>
                  </state>
                  <state id="receive_pongs">
                    <state id="waiting">
                      <!-- reset pong timer each time we receive anything -->
                      <transition event="message" port="socket" target="."/>
                      <!-- when not having received anything for 3s, decide the server and/or connection are (temporarily) dead -->
                      <transition after="5" target=".">
                        <!-- WORKAROUND: for some reason, target cannot be '../../../timeout' (limitation of 'main' SCCD, fixed in 'joeri' branch), so we generate an internal event to make the transition from higher up -->
                        <raise event="timeout"/>
                      </transition>
                    </state>
                  </state>
                  <!-- part of WORKAROUND, see above -->
                  <transition event="timeout" target="../timeout">
                    <raise event="timeout"/>
                  </transition>
                </parallel>
                <state id="timeout">
                  <!-- don't send pings - just wait for any observable server activity -->
                  <transition event="message" port="socket" target="../all_good">
                    <raise event="timeout_recovered"/>
                  </transition>
                </state>
              </state>
            </parallel>

            <state id="wait_reconnect">
              <!-- This is an intermediate state where we wait for a little while, before trying to reconnect -->
              <transition after="1" target="../connecting"/>
            </state>
          </state>
        </state><!-- socket region -->

      </parallel>

    </scxml>
  </class>
</diagram>