const assert = require('assert')
const {CompositeDisposable} = require('event-kit')
const Router = require('./router')
const BufferProxy = require('./buffer-proxy')
const EditorProxy = require('./editor-proxy')
const EditorProxyMetadata = require('./editor-proxy-metadata')
const StarOverlayNetwork = require('./star-overlay-network')
const Messages = require('./teletype-client_pb')
const NullPortalDelegate = require('./null-portal-delegate')
const FollowState = require('./follow-state')

module.exports =
class Portal {
  constructor ({id, hostPeerId, siteId, peerPool, connectionTimeout, tetherDisconnectWindow}) {
    this.id = id
    this.hostPeerId = hostPeerId
    this.siteId = siteId
    this.tetherDisconnectWindow = tetherDisconnectWindow
    this.isHost = isHostSiteId(this.siteId)
    this.siteIdsByPeerId = new Map()
    this.peerIdsBySiteId = new Map()
    this.editorProxiesById = new Map()
    this.bufferProxiesById = new Map()
    this.activeEditorProxiesBySiteId = new Map()
    this.activeEditorProxySubscriptions = new CompositeDisposable()
    this.tethersByFollowerId = new Map()
    this.disposables = new CompositeDisposable()
    this.disposed = false
    this.delegate = new NullPortalDelegate()

    this.peerPool = peerPool
    this.network = new StarOverlayNetwork({id, isHub: this.isHost, peerPool, connectionTimeout})
    this.router = new Router(this.network)

    if (this.isHost) {
      this.bindPeerIdToSiteId(this.network.getPeerId(), this.siteId)
      this.nextSiteId = 2
      this.nextBufferId = 1
      this.nextEditorId = 1
      this.disposables.add(
        this.router.onRequest(`/portals/${id}`, this.receiveSubscription.bind(this))
      )
    } else {
      this.editorProxiesMetadataById = new Map()
    }

    this.disposables.add(
      this.router.onNotification(`/portals/${id}`, this.receiveUpdate.bind(this)),
      this.network.onMemberLeave(this.siteDidLeave.bind(this))
    )
  }

  getLocalSiteId () {
    return this.siteId
  }

  dispose () {
    this.editorProxiesById.forEach((editorProxy) => {
      editorProxy.dispose()
    })

    this.bufferProxiesById.forEach((bufferProxy) => {
      bufferProxy.dispose()
    })

    this.disposables.dispose()
    this.router.dispose()
    this.network.dispose()

    this.delegate.dispose()
    this.disposed = true
  }

  async setDelegate (delegate) {
    this.delegate = delegate || new NullPortalDelegate()

    await this.delegate.updateTether(
      this.resolveFollowState(),
      this.getLocalActiveEditorProxy(),
      this.resolveLeaderPosition()
    )
  }

  async initialize () {
    try {
      this.disposables.add(await this.peerPool.listen())
    } catch (error) {
      this.dispose()
      throw error
    }
  }

  async join () {
    try {
      this.networkConnectionPromise = this.network.connectTo(this.hostPeerId)
      await this.networkConnectionPromise
    } catch (error) {
      this.dispose()
      throw error
    }
    const rawResponse = await this.router.request({recipientId: this.hostPeerId, channelId: `/portals/${this.id}`})
    const response = Messages.PortalSubscriptionResponse.deserializeBinary(rawResponse.body)

    response.getSiteIdsByPeerIdMap().forEach((siteId, peerId) => {
      this.bindPeerIdToSiteId(peerId, siteId)
    })
    this.siteId = this.siteIdsByPeerId.get(this.network.getPeerId())

    response.getEditorProxiesMetadataList().forEach((editorProxyMetadataMessage) => {
      this.deserializeEditorProxyMetadata(editorProxyMetadataMessage)
    })

    const tethers = response.getTethersList()
    for (let i = 0; i < tethers.length; i++) {
      const tether = tethers[i]
      this.tethersByFollowerId.set(tether.getFollowerSiteId(), {
        leaderId: tether.getLeaderSiteId(),
        state: tether.getState()
      })
    }

    const activeBufferProxies = response.getActiveBufferProxiesList()
    for (let i = 0; i < activeBufferProxies.length; i++) {
      const bufferProxyMessage = activeBufferProxies[i]
      this.deserializeBufferProxy(bufferProxyMessage)
    }

    const activeEditorProxies = response.getActiveEditorProxiesList()
    for (let i = 0; i < activeEditorProxies.length; i++) {
      const editorProxyMessage = activeEditorProxies[i]
      this.deserializeEditorProxy(editorProxyMessage)
    }

    response.getActiveEditorProxyIdsBySiteIdMap().forEach((editorProxyId, siteId) => {
      const editorProxy = this.editorProxiesById.get(editorProxyId)
      this.activeEditorProxiesBySiteId.set(siteId, editorProxy)
    })

    this.follow(1)
  }

  createBufferProxy (props) {
    const id = this.nextBufferId++
    const bufferProxy = new BufferProxy(Object.assign({
      id,
      hostPeerId: this.hostPeerId,
      siteId: this.siteId,
      router: this.router,
      didDispose: () => this.bufferProxiesById.delete(id)
    }, props))
    this.bufferProxiesById.set(id, bufferProxy)
    return bufferProxy
  }

  async findOrFetchBufferProxy (id) {
    if (id == null) return

    let bufferProxy = this.bufferProxiesById.get(id)
    if (!bufferProxy && !this.isHost) {
      bufferProxy = await this.fetchBufferProxy(id)
    }

    return bufferProxy
  }

  async fetchBufferProxy (id) {
    const response = await this.router.request({recipientId: this.hostPeerId, channelId: `/buffers/${id}`})
    if (response.ok) {
      const bufferProxyMessage = Messages.BufferProxy.deserializeBinary(response.body)
      return this.deserializeBufferProxy(bufferProxyMessage)
    }
  }

  deserializeBufferProxy (message) {
    const bufferProxy = BufferProxy.deserialize(message, {
      router: this.router,
      hostPeerId: this.hostPeerId,
      siteId: this.siteId,
      didDispose: () => this.bufferProxiesById.delete(bufferProxy.id)
    })
    this.bufferProxiesById.set(bufferProxy.id, bufferProxy)
    return bufferProxy
  }

  createEditorProxy (props) {
    const id = this.nextEditorId++
    const editorProxy = new EditorProxy(Object.assign({
      id,
      siteId: this.siteId,
      router: this.router,
      tetherDisconnectWindow: this.tetherDisconnectWindow,
      didDispose: () => this.editorProxiesById.delete(id),
      portal: this
    }, props))
    this.editorProxiesById.set(id, editorProxy)
    this.broadcastEditorProxyCreation(editorProxy)

    return editorProxy
  }

  async findOrFetchEditorProxy (id) {
    let editorProxy = this.editorProxiesById.get(id)
    if (!editorProxy && !this.isHost) {
      editorProxy = await this.fetchEditorProxy(id)
    }

    return editorProxy
  }

  async fetchEditorProxy (id) {
    if (id == null) return

    const response = await this.router.request({recipientId: this.hostPeerId, channelId: `/editors/${id}`})
    if (!response.ok) return

    const editorProxyMessage = Messages.EditorProxy.deserializeBinary(response.body)
    const bufferProxy = await this.findOrFetchBufferProxy(editorProxyMessage.getBufferProxyId())
    if (bufferProxy) {
      const editorProxy = this.deserializeEditorProxy(editorProxyMessage)
      editorProxy.hideSelections()
      return editorProxy
    }
  }

  deserializeEditorProxy (message) {
    const editorProxy = EditorProxy.deserialize(message, {
      router: this.router,
      siteId: this.siteId,
      bufferProxiesById: this.bufferProxiesById,
      didDispose: () => {
        this.delegate.didChangeEditorProxies()
        this.editorProxiesById.delete(editorProxy.id)
      },
      portal: this
    })
    this.editorProxiesById.set(editorProxy.id, editorProxy)

    const editorProxyMetadata = this.editorProxiesMetadataById.get(editorProxy.id)
    if (editorProxyMetadata) editorProxyMetadata.dispose()

    return editorProxy
  }

  deserializeEditorProxyMetadata (message) {
    const editorProxyMetadata = EditorProxyMetadata.deserialize(message, {
      siteId: this.siteId,
      router: this.router,
      didDispose: () => {
        if (!this.editorProxiesById.has(editorProxyMetadata.id)) {
          this.delegate.didChangeEditorProxies()
        }

        this.editorProxiesMetadataById.delete(editorProxyMetadata.id)
      }
    })
    this.editorProxiesMetadataById.set(editorProxyMetadata.id, editorProxyMetadata)
    return editorProxyMetadata
  }

  activateEditorProxy (newEditorProxy) {
    const oldEditorProxy = this.getLocalActiveEditorProxy()
    if (newEditorProxy != oldEditorProxy) {
      this.unfollow()
      if (oldEditorProxy) oldEditorProxy.hideSelections()
      if (newEditorProxy) newEditorProxy.showSelections()

      this.activeEditorProxiesBySiteId.set(this.siteId, newEditorProxy)
      this.subscribeToEditorProxyChanges(newEditorProxy)
      this.broadcastEditorProxySwitch(newEditorProxy)

      this.updateActivePositions()
    }
  }

  getLocalActiveEditorProxy () {
    return this.activeEditorProxyForSiteId(this.siteId)
  }

  activeEditorProxyForSiteId (siteId) {
    const leaderId = this.resolveLeaderSiteId(siteId)
    const followState = this.resolveFollowState(siteId)
    if (followState === FollowState.RETRACTED) {
      return this.activeEditorProxiesBySiteId.get(leaderId)
    } else {
      return this.activeEditorProxiesBySiteId.get(siteId)
    }
  }

  getEditorProxiesMetadata () {
    const editorProxiesMetadata = []

    this.editorProxiesMetadataById.forEach((editorProxyMetadata) => {
      editorProxiesMetadata.push(editorProxyMetadata)
    })

    this.editorProxiesById.forEach((editorProxy) => {
      editorProxiesMetadata.push(editorProxy.getMetadata())
    })

    return editorProxiesMetadata
  }

  getEditorProxyMetadata (editorProxyId) {
    const editorProxy = this.editorProxiesById.get(editorProxyId)
    if (editorProxy) {
      return editorProxy.getMetadata()
    } else {
      return this.editorProxiesMetadataById.get(editorProxyId)
    }
  }

  getActiveSiteIds () {
    const connectedMemberIds = this.network.getMemberIds()
    const activeSiteIds = []
    for (let i = 0; i < connectedMemberIds.length; i++) {
      const memberId = connectedMemberIds[i]
      const siteId = this.siteIdsByPeerId.get(memberId)
      if (siteId != null) activeSiteIds.push(siteId)
    }
    return activeSiteIds
  }

  getSiteIdentity (siteId) {
    const peerId = this.peerIdsBySiteId.get(siteId)
    return this.network.getMemberIdentity(peerId)
  }

  siteDidLeave ({peerId, connectionLost}) {
    const siteId = this.siteIdsByPeerId.get(peerId)

    this.editorProxiesById.forEach((editorProxy) => {
      if (isHostSiteId(siteId)) {
        editorProxy.hostDidDisconnect()
      } else {
        editorProxy.siteDidDisconnect(siteId)
      }
    })

    if (isHostSiteId(siteId)) {
      if (connectionLost) {
        this.delegate.hostDidLoseConnection()
      } else {
        this.delegate.hostDidClosePortal()
      }

      this.dispose()
    } else {
      this.delegate.siteDidLeave(siteId)
    }

    const tether = this.tethersByFollowerId.get(this.siteId)
    if (tether && siteId === tether.leaderId) {
      this.unfollow()
    }
    this.tethersByFollowerId.delete(siteId)
    this.activeEditorProxiesBySiteId.delete(siteId)
    this.updateActivePositions()
  }

  receiveSubscription ({senderId, requestId}) {
    this.assignNewSiteId(senderId)
    this.sendSubscriptionResponse(requestId)
    this.delegate.siteDidJoin(this.siteIdsByPeerId.get(senderId))
    this.updateActivePositions()
  }

  assignNewSiteId (peerId) {
    const siteId = this.nextSiteId++
    this.bindPeerIdToSiteId(peerId, siteId)

    const siteAssignmentMessage = new Messages.PortalUpdate.SiteAssignment()
    siteAssignmentMessage.setPeerId(peerId)
    siteAssignmentMessage.setSiteId(siteId)
    const updateMessage = new Messages.PortalUpdate()
    updateMessage.setSiteAssignment(siteAssignmentMessage)

    this.router.notify({channelId: `/portals/${this.id}`, body: updateMessage.serializeBinary()})
  }

  sendSubscriptionResponse (requestId) {
    const response = new Messages.PortalSubscriptionResponse()

    this.siteIdsByPeerId.forEach((siteId, peerId) => {
      response.getSiteIdsByPeerIdMap().set(peerId, siteId)
    })

    const editorProxiesMetadata = []
    this.editorProxiesById.forEach((editorProxy) => {
      editorProxiesMetadata.push(editorProxy.getMetadata().serialize())
    })
    response.setEditorProxiesMetadataList(editorProxiesMetadata)

    const activeBufferProxiesById = new Map()
    const activeEditorProxiesById = new Map()
    this.activeEditorProxiesBySiteId.forEach((editorProxy, siteId) => {
      if (editorProxy) {
        const {bufferProxy} = editorProxy
        if (!activeBufferProxiesById.has(bufferProxy.id)) {
          activeBufferProxiesById.set(bufferProxy.id, bufferProxy.serialize())
        }

        if (!activeEditorProxiesById.has(editorProxy.id)) {
          activeEditorProxiesById.set(editorProxy.id, editorProxy.serialize())
        }

        response.getActiveEditorProxyIdsBySiteIdMap().set(siteId, editorProxy.id)
      }
    })
    response.setActiveBufferProxiesList(Array.from(activeBufferProxiesById.values()))
    response.setActiveEditorProxiesList(Array.from(activeEditorProxiesById.values()))

    const tethers = []
    this.tethersByFollowerId.forEach((tether, followerId) => {
      const tetherMessage = new Messages.Tether()
      tetherMessage.setFollowerSiteId(followerId)
      tetherMessage.setLeaderSiteId(tether.leaderId)
      tetherMessage.setState(tether.state)
      tethers.push(tetherMessage)
    })
    response.setTethersList(tethers)

    this.router.respond({requestId, body: response.serializeBinary()})
  }

  async receiveUpdate ({senderId, body}) {
    const updateMessage = Messages.PortalUpdate.deserializeBinary(body)

    if (updateMessage.hasEditorProxyCreation()) {
      this.receiveEditorProxyCreation(updateMessage.getEditorProxyCreation())
    } else if (updateMessage.hasEditorProxySwitch()) {
      const senderSiteId = this.siteIdsByPeerId.get(senderId)
      await this.receiveEditorProxySwitch(senderSiteId, updateMessage.getEditorProxySwitch())
    } else if (updateMessage.hasSiteAssignment()) {
      this.receiveSiteAssignment(updateMessage.getSiteAssignment())
    } else if (updateMessage.hasTetherUpdate()) {
      this.receiveTetherUpdate(updateMessage.getTetherUpdate())
    } else {
      throw new Error('Received unknown update message')
    }
  }

  receiveEditorProxyCreation (editorProxyCreationMessage) {
    this.deserializeEditorProxyMetadata(editorProxyCreationMessage.getEditorProxyMetadata())
    this.delegate.didChangeEditorProxies()
  }

  async receiveEditorProxySwitch (senderSiteId, editorProxySwitch) {
    const editorProxyId = editorProxySwitch.getEditorProxyId()
    const editorProxy = await this.findOrFetchEditorProxy(editorProxyId)
    this.activeEditorProxiesBySiteId.set(senderSiteId, editorProxy)

    if (senderSiteId === this.resolveLeaderSiteId()) this.leaderDidUpdate()
    this.updateActivePositions()
  }

  receiveSiteAssignment (siteAssignment) {
    const siteId = siteAssignment.getSiteId()
    const peerId = siteAssignment.getPeerId()
    this.bindPeerIdToSiteId(peerId, siteId)
    if (this.network.getPeerId() !== peerId) {
      this.delegate.siteDidJoin(siteId)
    }
  }

  receiveTetherUpdate (tetherUpdate) {
    const oldResolvedLeaderId = this.resolveLeaderSiteId()
    const oldResolvedState = this.resolveFollowState()

    const followerSiteId = tetherUpdate.getFollowerSiteId()
    const leaderSiteId = tetherUpdate.getLeaderSiteId()
    const tetherState = tetherUpdate.getState()
    this.tethersByFollowerId.set(followerSiteId, {leaderId: leaderSiteId, state: tetherState})

    const newResolvedLeaderId = this.resolveLeaderSiteId()
    const newResolvedState = this.resolveFollowState()
    this.didChangeTetherState({oldResolvedState, oldResolvedLeaderId, newResolvedState, newResolvedLeaderId})
  }

  bindPeerIdToSiteId (peerId, siteId) {
    this.siteIdsByPeerId.set(peerId, siteId)
    this.peerIdsBySiteId.set(siteId, peerId)
  }

  subscribeToEditorProxyChanges (editorProxy) {
    this.activeEditorProxySubscriptions.dispose()
    this.activeEditorProxySubscriptions = new CompositeDisposable()
    if (editorProxy) {
      this.activeEditorProxySubscriptions.add(editorProxy.onDidScroll(this.activeEditorDidScroll.bind(this)))
      this.activeEditorProxySubscriptions.add(editorProxy.onDidUpdateLocalSelections(this.activeEditorDidUpdateLocalSelections.bind(this)))
      this.activeEditorProxySubscriptions.add(editorProxy.onDidUpdateRemoteSelections(this.activeEditorDidUpdateRemoteSelections.bind(this)))
      this.activeEditorProxySubscriptions.add(editorProxy.bufferProxy.onDidUpdateText(this.activeEditorDidUpdateText.bind(this)))
    }
  }

  activeEditorDidUpdateLocalSelections ({initialUpdate}) {
    this.lastLocalUpdateAt = Date.now()
    if (!initialUpdate && this.resolveFollowState() === FollowState.RETRACTED) {
      const localCursorPosition = this.getLocalActiveEditorProxy().cursorPositionForSiteId(this.siteId)
      const leaderPosition = this.resolveLeaderPosition()
      if (localCursorPosition && leaderPosition && !pointsEqual(localCursorPosition, leaderPosition)) {
        this.extendTether()
      }
    }

    this.updateActivePositions()
  }

  activeEditorDidUpdateRemoteSelections ({selectionLayerIdsBySiteId, initialUpdate}) {
    const leaderDidChangeSelections = selectionLayerIdsBySiteId.has(this.resolveLeaderSiteId())
    if (!initialUpdate && leaderDidChangeSelections) {
      this.leaderDidUpdate()
    }

    this.updateActivePositions()
  }

  activeEditorDidUpdateText ({remote}) {
    if (this.resolveFollowState() === FollowState.RETRACTED) {
      if (remote) {
        this.delegate.updateTether(FollowState.RETRACTED, this.getLocalActiveEditorProxy(), this.resolveLeaderPosition())
      } else {
        this.lastLocalUpdateAt = Date.now()
        this.extendTether()
      }
    }

    this.updateActivePositions()
  }

  activeEditorDidScroll () {
    const leaderPosition = this.resolveLeaderPosition()
    if (leaderPosition && this.getLocalActiveEditorProxy().isScrollNeededToViewPosition(leaderPosition)) {
      this.unfollow()
    }
  }

  follow (leaderSiteId) {
    this.setFollowState(FollowState.RETRACTED, leaderSiteId)
  }

  unfollow () {
    this.setFollowState(FollowState.DISCONNECTED)
  }

  extendTether () {
    this.setFollowState(FollowState.EXTENDED)
  }

  retractTether () {
    this.setFollowState(FollowState.RETRACTED)
  }

  getFollowedSiteId () {
    if (this.resolveFollowState() === FollowState.DISCONNECTED) {
      return null
    } else {
      return this.tethersByFollowerId.get(this.siteId).leaderId
    }
  }

  // Private
  leaderDidUpdate () {
    switch (this.resolveFollowState()) {
      case FollowState.RETRACTED:
        const editorProxy = this.getLocalActiveEditorProxy()
        this.subscribeToEditorProxyChanges(editorProxy)
        this.delegate.updateTether(FollowState.RETRACTED, editorProxy, this.resolveLeaderPosition())
        break
      case FollowState.EXTENDED:
        this.retractOrDisconnectTether()
        break
    }
  }

  // Private
  retractOrDisconnectTether () {
    const leaderPosition = this.resolveLeaderPosition()
    const leaderSiteId = this.resolveLeaderSiteId()
    const localActiveEditorProxy = this.getLocalActiveEditorProxy()
    const leaderActiveEditorProxy = this.activeEditorProxyForSiteId(leaderSiteId)
    const leaderPositionIsInvisible = (
      localActiveEditorProxy !== leaderActiveEditorProxy ||
      (leaderPosition && localActiveEditorProxy.isScrollNeededToViewPosition(leaderPosition))
    )

    const hasRecentlyPerformedLocalUpdate = (Date.now() - this.lastLocalUpdateAt) <= this.tetherDisconnectWindow
    if (leaderPositionIsInvisible) {
      if (hasRecentlyPerformedLocalUpdate) {
        this.unfollow()
      } else {
        this.retractTether()
      }
    }
  }

  // Private
  setFollowState (newState, newLeaderId) {
    const tether = this.tethersByFollowerId.get(this.siteId)
    const oldState = tether ? tether.state : null
    const oldResolvedState = this.resolveFollowState()
    const oldResolvedLeaderId = this.resolveLeaderSiteId()
    const oldLeaderId = tether ? tether.leaderId : null
    newLeaderId = newLeaderId == null ? oldLeaderId : newLeaderId
    if (newLeaderId == null) return

    this.tethersByFollowerId.set(this.siteId, {leaderId: newLeaderId, state: newState})

    const newResolvedState = this.resolveFollowState()
    const newResolvedLeaderId = this.resolveLeaderSiteId()
    this.didChangeTetherState({oldResolvedState, oldResolvedLeaderId, newResolvedState, newResolvedLeaderId})

    if (oldState !== newState || oldLeaderId !== newLeaderId) {
      const tetherMessage = new Messages.Tether()
      tetherMessage.setFollowerSiteId(this.siteId)
      tetherMessage.setLeaderSiteId(newLeaderId)
      tetherMessage.setState(newState)
      const updateMessage = new Messages.PortalUpdate()
      updateMessage.setTetherUpdate(tetherMessage)

      this.router.notify({channelId: `/portals/${this.id}`, body: updateMessage.serializeBinary()})
    }
  }

  didChangeTetherState ({oldResolvedState, oldResolvedLeaderId, newResolvedState, newResolvedLeaderId}) {
    const oldLeaderActiveEditorProxy = this.activeEditorProxiesBySiteId.get(oldResolvedLeaderId)
    const newLeaderActiveEditorProxy = this.activeEditorProxiesBySiteId.get(newResolvedLeaderId)

    if (newResolvedState === FollowState.RETRACTED) {
      this.editorProxiesById.forEach((editorProxy) => {
        editorProxy.hideSelections()
      })

      this.subscribeToEditorProxyChanges(newLeaderActiveEditorProxy)
    } else if (oldResolvedState === FollowState.RETRACTED) {
      this.activeEditorProxiesBySiteId.set(this.siteId, oldLeaderActiveEditorProxy)
      this.broadcastEditorProxySwitch(oldLeaderActiveEditorProxy)
      if (oldLeaderActiveEditorProxy) oldLeaderActiveEditorProxy.showSelections()
    }

    this.delegate.updateTether(newResolvedState, this.getLocalActiveEditorProxy(), this.resolveLeaderPosition())
    this.updateActivePositions()
  }

  updateActivePositions () {
    const activePositions = {}
    const activeSiteIds = this.getActiveSiteIds()
    for (let i = 0; i < activeSiteIds.length; i++) {
      const siteId = activeSiteIds[i]
      const editorProxy = this.activeEditorProxyForSiteId(siteId)
      if (editorProxy) {
        let position
        const followState = this.resolveFollowState(siteId)
        if (followState === FollowState.RETRACTED) {
          const leaderId = this.resolveLeaderSiteId(siteId)
          position = editorProxy.cursorPositionForSiteId(leaderId)
        } else {
          position = editorProxy.cursorPositionForSiteId(siteId)
        }

        activePositions[siteId] = {editorProxy, position, followState}
      } else {
        activePositions[siteId] = {editorProxy: null, position: null, followState: null}
      }
    }

    this.delegate.updateActivePositions(activePositions)
  }

  broadcastEditorProxyCreation (editorProxy) {
    const editorProxyCreationMessage = new Messages.PortalUpdate.EditorProxyCreation()
    editorProxyCreationMessage.setEditorProxyMetadata(editorProxy.getMetadata().serialize())
    const updateMessage = new Messages.PortalUpdate()
    updateMessage.setEditorProxyCreation(editorProxyCreationMessage)
    this.router.notify({channelId: `/portals/${this.id}`, body: updateMessage.serializeBinary()})
  }

  broadcastEditorProxySwitch (editorProxy) {
    const editorProxySwitchMessage = new Messages.PortalUpdate.EditorProxySwitch()
    if (editorProxy) editorProxySwitchMessage.setEditorProxyId(editorProxy.id)
    const updateMessage = new Messages.PortalUpdate()
    updateMessage.setEditorProxySwitch(editorProxySwitchMessage)
    this.router.notify({channelId: `/portals/${this.id}`, body: updateMessage.serializeBinary()})
  }

  resolveLeaderPosition (followerId = this.siteId) {
    const leaderId = this.resolveLeaderSiteId(followerId)
    const editorProxy = this.getLocalActiveEditorProxy()
    return editorProxy ? editorProxy.cursorPositionForSiteId(leaderId) : null
  }

  resolveFollowState (followerId = this.siteId) {
    const leaderId = this.resolveLeaderSiteId(followerId)
    if (followerId === leaderId) {
      return FollowState.DISCONNECTED
    } else {
      return this.tethersByFollowerId.get(followerId).state
    }
  }

  resolveLeaderSiteId (followerId = this.siteId) {
    const tether = this.tethersByFollowerId.get(followerId)
    if (!tether) return followerId

    const visitedSiteIds = new Set([followerId])
    let leaderId = tether.leaderId

    let nextTether = this.tethersByFollowerId.get(leaderId)
    while (nextTether && nextTether.state === FollowState.RETRACTED) {
      if (visitedSiteIds.has(leaderId)) {
        leaderId = Math.min(...Array.from(visitedSiteIds))
        break
      } else {
        visitedSiteIds.add(leaderId)
        leaderId = nextTether.leaderId
        nextTether = this.tethersByFollowerId.get(leaderId)
      }
    }

    return leaderId
  }
}

function isHostSiteId (siteId) {
  return siteId === 1
}

function pointsEqual (a, b) {
  return a.row === b.row && a.column === b.column
}
