package ws

import (
	"encoding/json"
	"net/http"
	"sync"

	"github.com/gorilla/mux"
	"github.com/gorilla/websocket"
	"github.com/mattermost/focalboard/server/auth"
	"github.com/mattermost/focalboard/server/model"
	"github.com/mattermost/focalboard/server/utils"

	"github.com/mattermost/mattermost-server/v6/shared/mlog"
)

func (wss *websocketSession) WriteJSON(v interface{}) error {
	wss.mu.Lock()
	defer wss.mu.Unlock()
	err := wss.conn.WriteJSON(v)
	return err
}

func (wss *websocketSession) isSubscribedToTeam(teamID string) bool {
	for _, id := range wss.teams {
		if id == teamID {
			return true
		}
	}

	return false
}

func (wss *websocketSession) isSubscribedToBlock(blockID string) bool {
	for _, id := range wss.blocks {
		if id == blockID {
			return true
		}
	}

	return false
}

// Server is a WebSocket server.
type Server struct {
	upgrader         websocket.Upgrader
	listeners        map[*websocketSession]bool
	listenersByTeam  map[string][]*websocketSession
	listenersByBlock map[string][]*websocketSession
	mu               sync.RWMutex
	auth             *auth.Auth
	singleUserToken  string
	isMattermostAuth bool
	logger           *mlog.Logger
	store            Store
}

type websocketSession struct {
	conn   *websocket.Conn
	userID string
	mu     sync.Mutex
	teams  []string
	blocks []string
}

func (wss *websocketSession) isAuthenticated() bool {
	return wss.userID != ""
}

// NewServer creates a new Server.
func NewServer(auth *auth.Auth, singleUserToken string, isMattermostAuth bool, logger *mlog.Logger, store Store) *Server {
	return &Server{
		listeners:        make(map[*websocketSession]bool),
		listenersByTeam:  make(map[string][]*websocketSession),
		listenersByBlock: make(map[string][]*websocketSession),
		upgrader: websocket.Upgrader{
			CheckOrigin: func(r *http.Request) bool {
				return true
			},
		},
		auth:             auth,
		singleUserToken:  singleUserToken,
		isMattermostAuth: isMattermostAuth,
		logger:           logger,
		store:            store,
	}
}

// RegisterRoutes registers routes.
func (ws *Server) RegisterRoutes(r *mux.Router) {
	r.HandleFunc("/ws", ws.handleWebSocket)
}

func (ws *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) {
	// Upgrade initial GET request to a websocket
	client, err := ws.upgrader.Upgrade(w, r, nil)
	if err != nil {
		ws.logger.Error("ERROR upgrading to websocket", mlog.Err(err))
		return
	}

	// create an empty session with websocket client
	wsSession := &websocketSession{
		conn:   client,
		userID: "",
		mu:     sync.Mutex{},
		teams:  []string{},
		blocks: []string{},
	}

	if ws.isMattermostAuth {
		wsSession.userID = r.Header.Get("Mattermost-User-Id")
	}

	ws.addListener(wsSession)

	// Make sure we close the connection when the function returns
	defer func() {
		ws.logger.Debug("DISCONNECT WebSocket", mlog.Stringer("client", wsSession.conn.RemoteAddr()))

		// Remove session from listeners
		ws.removeListener(wsSession)
		wsSession.conn.Close()
	}()

	// Simple message handling loop
	for {
		_, p, err := wsSession.conn.ReadMessage()
		if err != nil {
			ws.logger.Error("ERROR WebSocket",
				mlog.Stringer("client", wsSession.conn.RemoteAddr()),
				mlog.Err(err),
			)
			ws.removeListener(wsSession)
			break
		}

		var command WebsocketCommand

		err = json.Unmarshal(p, &command)
		if err != nil {
			// handle this error
			ws.logger.Error(`ERROR webSocket parsing command`, mlog.String("json", string(p)))

			continue
		}

		if command.Action == websocketActionAuth {
			ws.logger.Debug(`Command: AUTH`, mlog.Stringer("client", wsSession.conn.RemoteAddr()))
			ws.authenticateListener(wsSession, command.Token)

			continue
		}

		// if the client wants to subscribe to a set of blocks and it
		// is sending a read token, we don't need to check for
		// authentication
		if command.Action == websocketActionSubscribeBlocks {
			ws.logger.Debug(`Command: SUBSCRIBE_BLOCKS`,
				mlog.String("teamID", command.TeamID),
				mlog.Stringer("client", wsSession.conn.RemoteAddr()),
			)

			if !ws.isCommandReadTokenValid(command) {
				ws.logger.Error(`Rejected invalid read token`,
					mlog.Stringer("client", wsSession.conn.RemoteAddr()),
					mlog.String("action", command.Action),
					mlog.String("readToken", command.ReadToken),
				)

				continue
			}

			ws.subscribeListenerToBlocks(wsSession, command.BlockIDs)
			continue
		}

		if command.Action == websocketActionUnsubscribeBlocks {
			ws.logger.Debug(`Command: UNSUBSCRIBE_BLOCKS`,
				mlog.String("teamID", command.TeamID),
				mlog.Stringer("client", wsSession.conn.RemoteAddr()),
			)

			if !ws.isCommandReadTokenValid(command) {
				ws.logger.Error(`Rejected invalid read token`,
					mlog.Stringer("client", wsSession.conn.RemoteAddr()),
					mlog.String("action", command.Action),
					mlog.String("readToken", command.ReadToken),
				)

				continue
			}

			ws.unsubscribeListenerFromBlocks(wsSession, command.BlockIDs)
			continue
		}

		// if the command is not authenticated at this point, it will
		// not be processed
		if !wsSession.isAuthenticated() {
			ws.logger.Error(`Rejected unauthenticated message`,
				mlog.Stringer("client", wsSession.conn.RemoteAddr()),
				mlog.String("action", command.Action),
			)

			continue
		}

		switch command.Action {
		case websocketActionSubscribeTeam:
			ws.logger.Debug(`Command: SUBSCRIBE_TEAM`,
				mlog.String("teamID", command.TeamID),
				mlog.Stringer("client", wsSession.conn.RemoteAddr()),
			)

			// if single user mode, check that the userID is valid and
			// assume that the user has permission if so
			if len(ws.singleUserToken) != 0 {
				if wsSession.userID != model.SingleUser {
					continue
				}

				// if not in single user mode validate that the session
				// has permissions to the team
			} else {
				ws.logger.Debug("Not single user mode")
				if !ws.auth.DoesUserHaveTeamAccess(wsSession.userID, command.TeamID) {
					ws.logger.Error("WS user doesn't have team access", mlog.String("teamID", command.TeamID), mlog.String("userID", wsSession.userID))
					continue
				}
			}

			ws.subscribeListenerToTeam(wsSession, command.TeamID)
		case websocketActionUnsubscribeTeam:
			ws.logger.Debug(`Command: UNSUBSCRIBE_TEAM`,
				mlog.String("teamID", command.TeamID),
				mlog.Stringer("client", wsSession.conn.RemoteAddr()),
			)

			ws.unsubscribeListenerFromTeam(wsSession, command.TeamID)
		default:
			ws.logger.Error(`ERROR webSocket command, invalid action`, mlog.String("action", command.Action))
		}
	}
}

// isCommandReadTokenValid ensures that a command contains a read
// token and a set of block ids that said token is valid for.
func (ws *Server) isCommandReadTokenValid(command WebsocketCommand) bool {
	if len(command.TeamID) == 0 {
		return false
	}

	boardID := ""
	// all the blocks must be part of the same board
	for _, blockID := range command.BlockIDs {
		block, err := ws.store.GetBlock(blockID)
		if err != nil {
			return false
		}

		if boardID == "" {
			boardID = block.BoardID
			continue
		}

		if boardID != block.BoardID {
			return false
		}
	}

	// the read token must be valid for the board
	isValid, err := ws.auth.IsValidReadToken(boardID, command.ReadToken)
	if err != nil {
		ws.logger.Error(`ERROR when checking token validity`,
			mlog.String("teamID", command.TeamID),
			mlog.Err(err),
		)
		return false
	}

	return isValid
}

// addListener adds a listener to the websocket server. The listener
// should not receive any update from the server until it subscribes
// itself to some entity changes. Adding a listener to the server
// doesn't mean that it's authenticated in any way.
func (ws *Server) addListener(listener *websocketSession) {
	ws.mu.Lock()
	defer ws.mu.Unlock()
	ws.listeners[listener] = true
}

// removeListener removes a listener and all its subscriptions, if
// any, from the websockets server.
func (ws *Server) removeListener(listener *websocketSession) {
	ws.mu.Lock()
	defer ws.mu.Unlock()

	// remove the listener from its subscriptions, if any

	// team subscriptions
	for _, team := range listener.teams {
		ws.removeListenerFromTeam(listener, team)
	}

	// block subscriptions
	for _, block := range listener.blocks {
		ws.removeListenerFromBlock(listener, block)
	}

	delete(ws.listeners, listener)
}

// subscribeListenerToTeam safely modifies the listener and the
// server to subscribe the listener to a given team updates.
func (ws *Server) subscribeListenerToTeam(listener *websocketSession, teamID string) {
	if listener.isSubscribedToTeam(teamID) {
		return
	}

	ws.mu.Lock()
	defer ws.mu.Unlock()

	ws.listenersByTeam[teamID] = append(ws.listenersByTeam[teamID], listener)
	listener.teams = append(listener.teams, teamID)
}

// unsubscribeListenerFromTeam safely modifies the listener and
// the server data structures to remove the link between the listener
// and a given team ID.
func (ws *Server) unsubscribeListenerFromTeam(listener *websocketSession, teamID string) {
	if !listener.isSubscribedToTeam(teamID) {
		return
	}

	ws.mu.Lock()
	defer ws.mu.Unlock()

	ws.removeListenerFromTeam(listener, teamID)
}

// subscribeListenerToBlocks safely modifies the listener and the
// server to subscribe the listener to a given set of block updates.
func (ws *Server) subscribeListenerToBlocks(listener *websocketSession, blockIDs []string) {
	ws.mu.Lock()
	defer ws.mu.Unlock()

	for _, blockID := range blockIDs {
		if listener.isSubscribedToBlock(blockID) {
			continue
		}

		ws.listenersByBlock[blockID] = append(ws.listenersByBlock[blockID], listener)
		listener.blocks = append(listener.blocks, blockID)
	}
}

// unsubscribeListenerFromBlocks safely modifies the listener and the
// server data structures to remove the link between the listener and
// a given set of block IDs.
func (ws *Server) unsubscribeListenerFromBlocks(listener *websocketSession, blockIDs []string) {
	ws.mu.Lock()
	defer ws.mu.Unlock()

	for _, blockID := range blockIDs {
		if listener.isSubscribedToBlock(blockID) {
			ws.removeListenerFromBlock(listener, blockID)
		}
	}
}

// removeListenerFromTeam removes the listener from both its own
// block subscribed list and the server listeners by team map.
func (ws *Server) removeListenerFromTeam(listener *websocketSession, teamID string) {
	// we remove the listener from the team index
	newTeamListeners := []*websocketSession{}
	for _, l := range ws.listenersByTeam[teamID] {
		if l != listener {
			newTeamListeners = append(newTeamListeners, l)
		}
	}
	ws.listenersByTeam[teamID] = newTeamListeners

	// we remove the team from the listener subscription list
	newListenerTeams := []string{}
	for _, id := range listener.teams {
		if id != teamID {
			newListenerTeams = append(newListenerTeams, id)
		}
	}
	listener.teams = newListenerTeams
}

// removeListenerFromBlock removes the listener from both its own
// block subscribed list and the server listeners by block map.
func (ws *Server) removeListenerFromBlock(listener *websocketSession, blockID string) {
	// we remove the listener from the block index
	newBlockListeners := []*websocketSession{}
	for _, l := range ws.listenersByBlock[blockID] {
		if l != listener {
			newBlockListeners = append(newBlockListeners, l)
		}
	}
	ws.listenersByBlock[blockID] = newBlockListeners

	// we remove the block from the listener subscription list
	newListenerBlocks := []string{}
	for _, id := range listener.blocks {
		if id != blockID {
			newListenerBlocks = append(newListenerBlocks, id)
		}
	}
	listener.blocks = newListenerBlocks
}

func (ws *Server) getUserIDForToken(token string) string {
	if len(ws.singleUserToken) > 0 {
		if token == ws.singleUserToken {
			return model.SingleUser
		} else {
			return ""
		}
	}

	session, err := ws.auth.GetSession(token)
	if session == nil || err != nil {
		return ""
	}

	return session.UserID
}

func (ws *Server) authenticateListener(wsSession *websocketSession, token string) {
	ws.logger.Debug("authenticateListener",
		mlog.String("token", token),
		mlog.String("wsSession.userID", wsSession.userID),
	)
	if wsSession.isAuthenticated() {
		// Do not allow multiple auth calls (for security)
		ws.logger.Debug(
			"authenticateListener: Ignoring already authenticated session",
			mlog.String("userID", wsSession.userID),
			mlog.Stringer("client", wsSession.conn.RemoteAddr()),
		)
		return
	}

	// Authenticate session
	userID := ws.getUserIDForToken(token)
	if userID == "" {
		wsSession.conn.Close()
		return
	}

	// Authenticated
	wsSession.userID = userID
	ws.logger.Debug("authenticateListener: Authenticated", mlog.String("userID", userID), mlog.Stringer("client", wsSession.conn.RemoteAddr()))
}

// getListenersForBlock returns the listeners subscribed to a
// block changes.
func (ws *Server) getListenersForBlock(blockID string) []*websocketSession {
	return ws.listenersByBlock[blockID]
}

// getListenersForTeam returns the listeners subscribed to a
// team changes.
func (ws *Server) getListenersForTeam(teamID string) []*websocketSession {
	return ws.listenersByTeam[teamID]
}

// getListenersForTeamAndBoard returns the listeners subscribed to a
// team changes and members of a given board.
func (ws *Server) getListenersForTeamAndBoard(teamID, boardID string, ensureUsers ...string) []*websocketSession {
	members, err := ws.store.GetMembersForBoard(boardID)
	if err != nil {
		ws.logger.Error("error getting members for board",
			mlog.String("method", "getListenersForTeamAndBoard"),
			mlog.String("teamID", teamID),
			mlog.String("boardID", boardID),
		)
		return nil
	}

	memberMap := map[string]bool{}
	for _, member := range members {
		memberMap[member.UserID] = true
	}
	for _, id := range ensureUsers {
		memberMap[id] = true
	}

	memberIDs := []string{}
	for id := range memberMap {
		memberIDs = append(memberIDs, id)
	}

	listeners := []*websocketSession{}
	for _, memberID := range memberIDs {
		for _, listener := range ws.listenersByTeam[teamID] {
			if listener.userID == memberID {
				listeners = append(listeners, listener)
			}
		}
	}
	return listeners
}

// BroadcastBlockDelete broadcasts delete messages to clients.
func (ws *Server) BroadcastBlockDelete(teamID, blockID, boardID string) {
	now := utils.GetMillis()
	block := model.Block{}
	block.ID = blockID
	block.BoardID = boardID
	block.UpdateAt = now
	block.DeleteAt = now

	ws.BroadcastBlockChange(teamID, block)
}

// BroadcastBlockChange broadcasts update messages to clients.
func (ws *Server) BroadcastBlockChange(teamID string, block model.Block) {
	blockIDsToNotify := []string{block.ID, block.ParentID}

	message := UpdateBlockMsg{
		Action: websocketActionUpdateBlock,
		TeamID: teamID,
		Block:  block,
	}

	listeners := ws.getListenersForTeamAndBoard(teamID, block.BoardID)
	ws.logger.Trace("listener(s) for teamID",
		mlog.Int("listener_count", len(listeners)),
		mlog.String("teamID", teamID),
		mlog.String("boardID", block.BoardID),
	)

	for _, blockID := range blockIDsToNotify {
		listeners = append(listeners, ws.getListenersForBlock(blockID)...)
		ws.logger.Trace("listener(s) for blockID",
			mlog.Int("listener_count", len(listeners)),
			mlog.String("blockID", blockID),
		)
	}

	for _, listener := range listeners {
		ws.logger.Debug("Broadcast block change",
			mlog.String("teamID", teamID),
			mlog.String("blockID", block.ID),
			mlog.Stringer("remoteAddr", listener.conn.RemoteAddr()),
		)

		err := listener.WriteJSON(message)
		if err != nil {
			ws.logger.Error("broadcast error", mlog.Err(err))
			listener.conn.Close()
		}
	}
}

func (ws *Server) BroadcastCategoryChange(category model.Category) {
	message := UpdateCategoryMessage{
		Action:   websocketActionUpdateCategory,
		TeamID:   category.TeamID,
		Category: &category,
	}

	listeners := ws.getListenersForTeam(category.TeamID)
	ws.logger.Debug("listener(s) for teamID",
		mlog.Int("listener_count", len(listeners)),
		mlog.String("teamID", category.TeamID),
		mlog.String("categoryID", category.ID),
	)

	for _, listener := range listeners {
		ws.logger.Debug("Broadcast block change",
			mlog.Int("listener_count", len(listeners)),
			mlog.String("teamID", category.TeamID),
			mlog.String("categoryID", category.ID),
			mlog.Stringer("remoteAddr", listener.conn.RemoteAddr()),
		)

		if err := listener.WriteJSON(message); err != nil {
			ws.logger.Error("broadcast category change error", mlog.Err(err))
			listener.conn.Close()
		}
	}
}

func (ws *Server) BroadcastCategoryBoardChange(teamID, userID string, boardCategory model.BoardCategoryWebsocketData) {
	message := UpdateCategoryMessage{
		Action:          websocketActionUpdateCategoryBoard,
		TeamID:          teamID,
		BoardCategories: &boardCategory,
	}

	listeners := ws.getListenersForTeam(teamID)
	ws.logger.Debug("listener(s) for teamID",
		mlog.Int("listener_count", len(listeners)),
		mlog.String("teamID", teamID),
		mlog.String("categoryID", boardCategory.CategoryID),
		mlog.String("blockID", boardCategory.BoardID),
	)

	for _, listener := range listeners {
		ws.logger.Debug("Broadcast block change",
			mlog.Int("listener_count", len(listeners)),
			mlog.String("teamID", teamID),
			mlog.String("categoryID", boardCategory.CategoryID),
			mlog.String("blockID", boardCategory.BoardID),
			mlog.Stringer("remoteAddr", listener.conn.RemoteAddr()),
		)

		if err := listener.WriteJSON(message); err != nil {
			ws.logger.Error("broadcast category change error", mlog.Err(err))
			listener.conn.Close()
		}
	}
}

// BroadcastConfigChange broadcasts update messages to clients.
func (ws *Server) BroadcastConfigChange(clientConfig model.ClientConfig) {
	message := UpdateClientConfig{
		Action:       websocketActionUpdateConfig,
		ClientConfig: clientConfig,
	}

	listeners := ws.listeners
	ws.logger.Debug("broadcasting config change to listener(s)",
		mlog.Int("listener_count", len(listeners)),
	)

	for listener := range listeners {
		ws.logger.Debug("Broadcast Config change",
			mlog.Stringer("remoteAddr", listener.conn.RemoteAddr()),
		)
		err := listener.WriteJSON(message)
		if err != nil {
			ws.logger.Error("broadcast error", mlog.Err(err))
			listener.conn.Close()
		}
	}
}

func (ws *Server) BroadcastBoardChange(teamID string, board *model.Board) {
	message := UpdateBoardMsg{
		Action: websocketActionUpdateBoard,
		TeamID: teamID,
		Board:  board,
	}

	listeners := ws.getListenersForTeamAndBoard(teamID, board.ID)
	ws.logger.Trace("listener(s) for teamID and boardID",
		mlog.Int("listener_count", len(listeners)),
		mlog.String("teamID", teamID),
		mlog.String("boardID", board.ID),
	)

	for _, listener := range listeners {
		ws.logger.Debug("Broadcast board change",
			mlog.String("teamID", teamID),
			mlog.String("boardID", board.ID),
			mlog.Stringer("remoteAddr", listener.conn.RemoteAddr()),
		)

		err := listener.WriteJSON(message)
		if err != nil {
			ws.logger.Error("broadcast error", mlog.Err(err))
			listener.conn.Close()
		}
	}
}

func (ws *Server) BroadcastBoardDelete(teamID, boardID string) {
	now := utils.GetMillis()
	board := &model.Board{}
	board.ID = boardID
	board.TeamID = teamID
	board.UpdateAt = now
	board.DeleteAt = now

	ws.BroadcastBoardChange(teamID, board)
}

func (ws *Server) BroadcastMemberChange(teamID, boardID string, member *model.BoardMember) {
	message := UpdateMemberMsg{
		Action: websocketActionUpdateMember,
		TeamID: teamID,
		Member: member,
	}

	listeners := ws.getListenersForTeamAndBoard(teamID, boardID)
	ws.logger.Trace("listener(s) for teamID and boardID",
		mlog.Int("listener_count", len(listeners)),
		mlog.String("teamID", teamID),
		mlog.String("boardID", boardID),
	)

	for _, listener := range listeners {
		ws.logger.Debug("Broadcast member change",
			mlog.String("teamID", teamID),
			mlog.String("boardID", boardID),
			mlog.Stringer("remoteAddr", listener.conn.RemoteAddr()),
		)

		err := listener.WriteJSON(message)
		if err != nil {
			ws.logger.Error("broadcast error", mlog.Err(err))
			listener.conn.Close()
		}
	}
}

func (ws *Server) BroadcastMemberDelete(teamID, boardID, userID string) {
	message := UpdateMemberMsg{
		Action: websocketActionDeleteMember,
		TeamID: teamID,
		Member: &model.BoardMember{UserID: userID, BoardID: boardID},
	}

	// when fetching the members of the board that should receive the
	// member deletion message, the deleted member will not be one of
	// them, so we need to ensure they receive the message
	listeners := ws.getListenersForTeamAndBoard(teamID, boardID, userID)
	ws.logger.Trace("listener(s) for teamID and boardID",
		mlog.Int("listener_count", len(listeners)),
		mlog.String("teamID", teamID),
		mlog.String("boardID", boardID),
	)

	for _, listener := range listeners {
		ws.logger.Debug("Broadcast member removal",
			mlog.String("teamID", teamID),
			mlog.String("boardID", boardID),
			mlog.Stringer("remoteAddr", listener.conn.RemoteAddr()),
		)

		err := listener.WriteJSON(message)
		if err != nil {
			ws.logger.Error("broadcast error", mlog.Err(err))
			listener.conn.Close()
		}
	}
}

func (ws *Server) BroadcastSubscriptionChange(workspaceID string, subscription *model.Subscription) {
	// not implemented for standalone server.
}

func (ws *Server) BroadcastCardLimitTimestampChange(cardLimitTimestamp int64) {
	// not implemented for standalone server.
}
