import { isSecureURL } from '@baserow/modules/core/utils/string'
import { logoutAndRedirectToLogin } from '@baserow/modules/core/utils/auth'

export class RealTimeHandler {
  constructor(context) {
    this.context = context
    this.socket = null
    this.connected = false
    this.reconnect = false
    this.anonymous = false
    this.reconnectTimeout = null
    this.attempts = 0
    this.events = {}
    this.pages = []
    this.subscribedToPages = true
    this.lastToken = null
    this.authenticationSuccess = true
    this.registerCoreEvents()
  }

  /**
   * Creates a new connection with to the web socket so that real time updates can be
   * received.
   */
  connect(reconnect = true, anonymous = false) {
    this.reconnect = reconnect
    this.anonymous = anonymous

    const jwtToken = this.context.store.getters['auth/token']
    const token = anonymous ? jwtToken || 'anonymous' : jwtToken

    // If the user is already connected to the web socket, we don't have to do
    // anything.
    if (this.connected) {
      return
    }

    // Stop connecting if we have already tried more than 10 times, if we do not have
    // an authentication token or if the server has already responded with a failed
    // authentication error and the token has not changed.
    if (
      this.attempts > 10 ||
      token === null ||
      (!this.authenticationSuccess && token === this.lastToken)
    ) {
      this.context.store.dispatch('toast/setFailedConnecting', true)
      return
    }

    this.lastToken = token

    // The web socket url is the same as the PUBLIC_BACKEND_URL apart from the
    // protocol.
    const rawUrl = this.context.app.$config.PUBLIC_BACKEND_URL
    const url = new URL(rawUrl)
    url.protocol = isSecureURL(rawUrl) ? 'wss:' : 'ws:'
    url.pathname = '/ws/core/'

    this.socket = new WebSocket(`${url}?jwt_token=${token}`)
    this.socket.onopen = () => {
      this.context.store.dispatch('toast/setConnecting', false)
      this.connected = true
      this.attempts = 0

      // If the client needs to be subscribed to a page we can do that directly
      // after connecting.
      if (!this.subscribedToPages) {
        this.subscribeToPages()
      }
    }

    /**
     * The received messages are always JSON so we need to the parse it, extract the
     * type and call the correct event.
     */
    this.socket.onmessage = (message) => {
      let data = {}

      try {
        data = JSON.parse(message.data)
      } catch {
        return
      }

      if (
        Object.prototype.hasOwnProperty.call(data, 'type') &&
        Object.prototype.hasOwnProperty.call(this.events, data.type)
      ) {
        this.events[data.type](this.context, data)
      }
    }

    /**
     * When the connection closes we want to reconnect immediately because we don't
     * want to miss any important real time updates. After the first attempt we want to
     * delay retry with 5 seconds.
     */
    this.socket.onclose = () => {
      this.connected = false
      // By default the user not subscribed to a page a.k.a `null`, so if the current
      // page is already null we can mark it as subscribed.
      this.subscribedToPages = this.pages.length === 0
      this.delayedReconnect()
    }
  }

  /**
   * If reconnecting is enabled then a timeout is created that will try to connect
   * to the backend one more time.
   */
  delayedReconnect() {
    if (!this.reconnect) {
      return
    }

    this.attempts++
    this.context.store.dispatch('toast/setConnecting', true)

    this.reconnectTimeout = setTimeout(
      () => {
        this.connect(true, this.anonymous)
      },
      // After the first try, we want to try again every 5 seconds.
      this.attempts > 1 ? 5000 : 0
    )
  }

  /**
   * Subscribes the client to a given page. After subscribing the client will
   * receive updated related to that page. This is for example used when a user
   * opens a table page.
   */
  subscribe(page, parameters) {
    const pageScope = {
      page,
      parameters,
    }

    if (
      !this.pages.some(
        (elem) => JSON.stringify(elem) === JSON.stringify(pageScope)
      )
    ) {
      this.pages.push(pageScope)
      // If the client is already connected we can
      // subscribe to updates for all pages.
      if (this.connected) {
        this.subscribeToPage(page, parameters)
      } else {
        this.subscribedToPages = false
      }
    }
  }

  /**
   * Unsubscribes the client from a given page. The client will
   * stop receiving updates related to that page.
   */
  unsubscribe(page, parameters) {
    this.pages = this.pages.filter(
      (item) => JSON.stringify(item) !== JSON.stringify({ page, parameters })
    )
    this.socket.send(
      JSON.stringify({
        remove_page: page,
        ...parameters,
      })
    )
  }

  /*
   * Subscribes the client to a new page if the client is
   * connected.
   */
  subscribeToPage(page, parameters) {
    if (this.connected) {
      this.socket.send(
        JSON.stringify({
          page: page === null ? '' : page,
          ...parameters,
        })
      )
    }
  }

  /**
   * Requests real time updates for the list of pages that
   * have been collected by the subscribe() call.
   */
  subscribeToPages() {
    if (this.subscribedToPages) {
      return
    }

    for (const { page, parameters } of this.pages) {
      this.subscribeToPage(page, parameters)
    }

    this.subscribedToPages = true
  }

  /**
   * Disconnects the socket and resets all the variables. The can be used when
   * navigating to another page that doesn't require updates.
   */
  disconnect() {
    if (this.connected) {
      this.socket.close()
    }

    this.context.store.dispatch('toast/setConnecting', false)
    this.context.store.dispatch('toast/setFailedConnecting', false)
    clearTimeout(this.reconnectTimeout)
    this.reconnect = false
    this.attempts = 0
    this.connected = false
  }

  /**
   * Registers a new event with the event registry.
   */
  registerEvent(type, callback) {
    this.events[type] = callback
  }

  /**
   * Registers all the core event handlers, which is for the workspaces and applications.
   */
  registerCoreEvents() {
    // When the authentication is successful we want to store the web socket id in
    // auth store. Every AJAX request will include the web socket id as header, this
    // way the backend knows that this client does not has to receive the event
    // because we already know about the change.
    this.registerEvent('authentication', ({ store }, data) => {
      store.dispatch('auth/setWebSocketId', data.web_socket_id)

      // Store if the authentication was successful in order to prevent retries that
      // will fail.
      this.authenticationSuccess = data.success
    })

    this.registerEvent('user_data_updated', ({ store }, data) => {
      store.dispatch('auth/forceUpdateUserData', data.user_data)
    })

    this.registerEvent('user_updated', ({ store }, data) => {
      store.dispatch('workspace/forceUpdateWorkspaceUserAttributes', {
        userId: data.user.id,
        values: {
          name: data.user.first_name,
        },
      })
    })

    this.registerEvent('user_deleted', ({ store }, data) => {
      store.dispatch('workspace/forceUpdateWorkspaceUserAttributes', {
        userId: data.user.id,
        values: {
          to_be_deleted: true,
        },
      })
    })

    this.registerEvent('user_restored', ({ store }, data) => {
      store.dispatch('workspace/forceUpdateWorkspaceUserAttributes', {
        userId: data.user.id,
        values: {
          to_be_deleted: false,
        },
      })
    })

    this.registerEvent('user_permanently_deleted', ({ store }, data) => {
      store.dispatch('workspace/forceDeleteUser', {
        userId: data.user_id,
      })
    })

    this.registerEvent('group_created', ({ store }, data) => {
      store.dispatch('workspace/forceCreate', data.workspace)
    })

    this.registerEvent('group_restored', ({ store }, data) => {
      store.dispatch('workspace/forceCreate', data.workspace)
      store.dispatch('application/forceCreateAll', data.applications)
    })

    this.registerEvent('group_updated', ({ store }, data) => {
      const workspace = store.getters['workspace/get'](data.workspace_id)
      if (workspace !== undefined) {
        store.dispatch('workspace/forceUpdate', {
          workspace,
          values: data.workspace,
        })
      }
    })

    this.registerEvent('group_deleted', ({ store }, data) => {
      const workspace = store.getters['workspace/get'](data.workspace_id)
      if (workspace !== undefined) {
        store.dispatch('workspace/forceDelete', workspace)
      }
    })

    this.registerEvent('groups_reordered', ({ store }, data) => {
      store.dispatch('workspace/forceOrder', data.workspace_ids)
    })

    this.registerEvent('group_user_added', ({ store }, data) => {
      store.dispatch('workspace/forceAddWorkspaceUser', {
        workspaceId: data.workspace_id,
        values: data.workspace_user,
      })
    })

    this.registerEvent('group_user_updated', ({ store }, data) => {
      store.dispatch('workspace/forceUpdateWorkspaceUser', {
        id: data.id,
        workspaceId: data.workspace_id,
        values: data.workspace_user,
      })
    })

    this.registerEvent('group_user_deleted', ({ store }, data) => {
      store.dispatch('workspace/forceDeleteWorkspaceUser', {
        id: data.id,
        workspaceId: data.workspace_id,
        values: data.workspace_user,
      })
    })

    this.registerEvent('application_created', ({ store }, data) => {
      store.dispatch('application/forceCreate', data.application)
    })

    this.registerEvent('application_updated', ({ store }, data) => {
      const application = store.getters['application/get'](data.application_id)
      if (application !== undefined) {
        store.dispatch('application/forceUpdate', {
          application,
          data: data.application,
        })
      }
    })

    this.registerEvent('application_deleted', ({ store }, data) => {
      const application = store.getters['application/get'](data.application_id)
      if (application !== undefined) {
        store.dispatch('application/forceDelete', application)
      }
    })

    this.registerEvent('applications_reordered', ({ store }, data) => {
      const workspace = store.getters['workspace/get'](data.workspace_id)
      if (workspace !== undefined) {
        store.commit('application/ORDER_ITEMS', {
          workspace,
          order: data.order,
          isHashed: true,
        })
      }
    })

    // invitations
    this.registerEvent(
      'workspace_invitation_updated_or_created',
      ({ store }, data) => {
        store.dispatch(
          'auth/forceUpdateOrCreateWorkspaceInvitation',
          data.invitation
        )
      }
    )

    this.registerEvent('workspace_invitation_accepted', ({ store }, data) => {
      store.dispatch('auth/forceAcceptWorkspaceInvitation', data.invitation)
    })

    this.registerEvent('workspace_invitation_rejected', ({ store }, data) => {
      store.dispatch('auth/forceRejectWorkspaceInvitation', data.invitation)
    })

    // notifications
    this.registerEvent('notifications_created', ({ store }, data) => {
      store.dispatch('notification/forceCreateInBulk', {
        notifications: data.notifications,
      })
    })

    this.registerEvent('notifications_fetch_required', ({ store }, data) => {
      store.dispatch('notification/forceRefetch', {
        notificationsAdded: data.notifications_added,
      })
    })

    this.registerEvent('notification_marked_as_read', ({ store }, data) => {
      store.dispatch('notification/forceMarkAsRead', {
        notification: data.notification,
      })
    })

    this.registerEvent('all_notifications_marked_as_read', ({ store }) => {
      store.dispatch('notification/forceMarkAllAsRead')
    })

    this.registerEvent('all_notifications_cleared', ({ store }) => {
      store.dispatch('notification/forceClearAll')
    })

    this.registerEvent('force_disconnect', ({ store }) => {
      this.reconnect = false
      logoutAndRedirectToLogin(this.context.app.router, store, false, true)
    })
  }
}

export default function (context, inject) {
  inject('realtime', new RealTimeHandler(context))
}