/**
 * AccessibleBlockly
 *
 * Copyright 2016 Google Inc.
 * https://developers.google.com/blockly/
 *
 * Licensed under the Apache License, Version 2.0 (the 'License');
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *   http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an 'AS IS' BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

/**
 * @fileoverview Angular2 Service that handles keyboard navigation on workspace
 * block groups (internally represented as trees). This is a singleton service
 * for the entire application.
 *
 * @author madeeha@google.com (Madeeha Ghori)
 */

goog.provide('blocklyApp.TreeService');

goog.require('blocklyApp.UtilsService');

goog.require('blocklyApp.AudioService');
goog.require('blocklyApp.BlockConnectionService');
goog.require('blocklyApp.BlockOptionsModalService');
goog.require('blocklyApp.NotificationsService');
goog.require('blocklyApp.VariableModalService');


blocklyApp.TreeService = ng.core.Class({
  constructor: [
    blocklyApp.AudioService,
    blocklyApp.BlockConnectionService,
    blocklyApp.BlockOptionsModalService,
    blocklyApp.NotificationsService,
    blocklyApp.UtilsService,
    blocklyApp.VariableModalService,
    function(
        audioService, blockConnectionService, blockOptionsModalService,
        notificationsService, utilsService, variableModalService) {
      this.audioService = audioService;
      this.blockConnectionService = blockConnectionService;
      this.blockOptionsModalService = blockOptionsModalService;
      this.notificationsService = notificationsService;
      this.utilsService = utilsService;
      this.variableModalService = variableModalService;

      // The suffix used for all IDs of block root elements.
      this.BLOCK_ROOT_ID_SUFFIX_ = blocklyApp.BLOCK_ROOT_ID_SUFFIX;
      // Maps tree IDs to the IDs of their active descendants.
      this.activeDescendantIds_ = {};
      // Array containing all the sidebar button elements.
      this.sidebarButtonElements_ = Array.from(
          document.querySelectorAll('button.blocklySidebarButton'));
    }
  ],
  scrollToElement_: function(elementId) {
    var element = document.getElementById(elementId);
    var documentElement = document.body || document.documentElement;
    if (element.offsetTop < documentElement.scrollTop ||
        element.offsetTop > documentElement.scrollTop + window.innerHeight) {
      window.scrollTo(0, element.offsetTop - 10);
    }
  },

  isLi_: function(node) {
    return node.tagName == 'LI';
  },
  getParentLi_: function(element) {
    var nextNode = element.parentNode;
    while (nextNode && !this.isLi_(nextNode)) {
      nextNode = nextNode.parentNode;
    }
    return nextNode;
  },
  getFirstChildLi_: function(element) {
    var childList = element.children;
    for (var i = 0; i < childList.length; i++) {
      if (this.isLi_(childList[i])) {
        return childList[i];
      } else {
        var potentialElement = this.getFirstChildLi_(childList[i]);
        if (potentialElement) {
          return potentialElement;
        }
      }
    }
    return null;
  },
  getLastChildLi_: function(element) {
    var childList = element.children;
    for (var i = childList.length - 1; i >= 0; i--) {
      if (this.isLi_(childList[i])) {
        return childList[i];
      } else {
        var potentialElement = this.getLastChildLi_(childList[i]);
        if (potentialElement) {
          return potentialElement;
        }
      }
    }
    return null;
  },
  getInitialSiblingLi_: function(element) {
    while (true) {
      var previousSibling = this.getPreviousSiblingLi_(element);
      if (previousSibling && previousSibling.id != element.id) {
        element = previousSibling;
      } else {
        return element;
      }
    }
  },
  getPreviousSiblingLi_: function(element) {
    if (element.previousElementSibling) {
      var sibling = element.previousElementSibling;
      return this.isLi_(sibling) ? sibling : this.getLastChildLi_(sibling);
    } else {
      var parent = element.parentNode;
      while (parent && parent.tagName != 'OL') {
        if (parent.previousElementSibling) {
          var node = parent.previousElementSibling;
          return this.isLi_(node) ? node : this.getLastChildLi_(node);
        } else {
          parent = parent.parentNode;
        }
      }
      return null;
    }
  },
  getNextSiblingLi_: function(element) {
    if (element.nextElementSibling) {
      var sibling = element.nextElementSibling;
      return this.isLi_(sibling) ? sibling : this.getFirstChildLi_(sibling);
    } else {
      var parent = element.parentNode;
      while (parent && parent.tagName != 'OL') {
        if (parent.nextElementSibling) {
          var node = parent.nextElementSibling;
          return this.isLi_(node) ? node : this.getFirstChildLi_(node);
        } else {
          parent = parent.parentNode;
        }
      }
      return null;
    }
  },
  getFinalSiblingLi_: function(element) {
    while (true) {
      var nextSibling = this.getNextSiblingLi_(element);
      if (nextSibling && nextSibling.id != element.id) {
        element = nextSibling;
      } else {
        return element;
      }
    }
  },

  // Returns a list of all focus targets in the workspace, including the
  // "Create new group" button that appears when no blocks are present.
  getWorkspaceFocusTargets_: function() {
    return Array.from(
        document.querySelectorAll('.blocklyWorkspaceFocusTarget'));
  },
  getAllFocusTargets_: function() {
    return this.getWorkspaceFocusTargets_().concat(this.sidebarButtonElements_);
  },
  getNextFocusTargetId_: function(treeId) {
    var trees = this.getAllFocusTargets_();
    for (var i = 0; i < trees.length - 1; i++) {
      if (trees[i].id == treeId) {
        return trees[i + 1].id;
      }
    }
    return null;
  },
  getPreviousFocusTargetId_: function(treeId) {
    var trees = this.getAllFocusTargets_();
    for (var i = trees.length - 1; i > 0; i--) {
      if (trees[i].id == treeId) {
        return trees[i - 1].id;
      }
    }
    return null;
  },

  getActiveDescId: function(treeId) {
    return this.activeDescendantIds_[treeId] || '';
  },
  // Set the active desc for this tree to its first child.
  initActiveDesc: function(treeId) {
    var tree = document.getElementById(treeId);
    this.setActiveDesc(this.getFirstChildLi_(tree).id, treeId);
  },
  // Make a given element the active descendant of a given tree.
  setActiveDesc: function(newActiveDescId, treeId) {
    if (this.getActiveDescId(treeId)) {
      this.clearActiveDesc(treeId);
    }
    document.getElementById(newActiveDescId).classList.add(
        'blocklyActiveDescendant');
    this.activeDescendantIds_[treeId] = newActiveDescId;

    // Scroll the new active desc into view, if needed. This has no effect
    // for blind users, but is helpful for sighted onlookers.
    this.scrollToElement_(newActiveDescId);
  },
  // This clears the active descendant of the given tree. It is used just
  // before the tree is deleted.
  clearActiveDesc: function(treeId) {
    var activeDesc = document.getElementById(this.getActiveDescId(treeId));
    if (activeDesc) {
      activeDesc.classList.remove('blocklyActiveDescendant');
    }

    if (this.activeDescendantIds_[treeId]) {
      delete this.activeDescendantIds_[treeId];
    }
  },
  clearAllActiveDescs: function() {
    for (var treeId in this.activeDescendantIds_) {
      var activeDesc = document.getElementById(this.getActiveDescId(treeId));
      if (activeDesc) {
        activeDesc.classList.remove('blocklyActiveDescendant');
      }
    }

    this.activeDescendantIds_ = {};
  },

  isTreeRoot_: function(element) {
    return element.classList.contains('blocklyTree');
  },
  getBlockRootId_: function(blockId) {
    return blockId + this.BLOCK_ROOT_ID_SUFFIX_;
  },
  // Return the 'lowest' Blockly block in the DOM tree that contains the given
  // DOM element.
  getContainingBlock_: function(domElement) {
    var potentialBlockRoot = domElement;
    while (potentialBlockRoot.id.indexOf(this.BLOCK_ROOT_ID_SUFFIX_) === -1) {
      potentialBlockRoot = potentialBlockRoot.parentNode;
    }

    var blockRootId = potentialBlockRoot.id;
    var blockId = blockRootId.substring(
        0, blockRootId.length - this.BLOCK_ROOT_ID_SUFFIX_.length);
    return blocklyApp.workspace.getBlockById(blockId);
  },
  isTopLevelBlock_: function(block) {
    return !block.getParent();
  },
  // Returns whether the given block is at the top level, and has no siblings.
  isIsolatedTopLevelBlock_: function(block) {
    var blockHasNoSiblings = (
        (!block.nextConnection ||
         !block.nextConnection.targetConnection) &&
        (!block.previousConnection ||
         !block.previousConnection.targetConnection));
    return this.isTopLevelBlock_(block) && blockHasNoSiblings;
  },
  safelyRemoveBlock_: function(block, deleteBlockFunc, areNextBlocksRemoved) {
    // Runs the given deleteBlockFunc (which should have the effect of deleting
    // the given block, and possibly others after it if `areNextBlocksRemoved`
    // is true) and then does one of two things:
    // - If the deleted block was an isolated top-level block, or it is a top-
    //   level block and the next blocks are going to be removed, this means
    //   the current tree has no more blocks after the deletion. So, pick a new
    //   tree to focus on.
    // - Otherwise, set the correct new active desc for the current tree.
    var treeId = this.getTreeIdForBlock(block.id);

    var treeCeasesToExist = areNextBlocksRemoved ?
        this.isTopLevelBlock_(block) : this.isIsolatedTopLevelBlock_(block);

    if (treeCeasesToExist) {
      // Find the node to focus on after the deletion happens.
      var nextElementToFocusOn = null;
      var focusTargets = this.getWorkspaceFocusTargets_();
      for (var i = 0; i < focusTargets.length; i++) {
        if (focusTargets[i].id == treeId) {
          if (i + 1 < focusTargets.length) {
            nextElementToFocusOn = focusTargets[i + 1];
          } else if (i > 0) {
            nextElementToFocusOn = focusTargets[i - 1];
          }
          break;
        }
      }

      this.clearActiveDesc(treeId);
      deleteBlockFunc();
      // Invoke a digest cycle, so that the DOM settles (and the "Create new
      // group" button in the workspace shows up, if applicable).
      setTimeout(function() {
        if (nextElementToFocusOn) {
          nextElementToFocusOn.focus();
        } else {
          document.getElementById(
              blocklyApp.ID_FOR_EMPTY_WORKSPACE_BTN).focus();
        }
      });
    } else {
      var blockRootId = this.getBlockRootId_(block.id);
      var blockRootElement = document.getElementById(blockRootId);

      // Find the new active desc for the current tree by trying the following
      // possibilities in order: the parent, the next sibling, and the previous
      // sibling. (If `areNextBlocksRemoved` is true, the next sibling would be
      // moved together with the moved block, so we don't check it.)
      if (areNextBlocksRemoved) {
        var newActiveDesc =
            this.getParentLi_(blockRootElement) ||
            this.getPreviousSiblingLi_(blockRootElement);
      } else {
        var newActiveDesc =
            this.getParentLi_(blockRootElement) ||
            this.getNextSiblingLi_(blockRootElement) ||
            this.getPreviousSiblingLi_(blockRootElement);
      }

      this.clearActiveDesc(treeId);
      deleteBlockFunc();
      // Invoke a digest cycle, so that the DOM settles.
      var that = this;
      setTimeout(function() {
        that.setActiveDesc(newActiveDesc.id, treeId);
        document.getElementById(treeId).focus();
      });
    }
  },
  getTreeIdForBlock: function(blockId) {
    // Walk up the DOM until we get to the root element of the tree.
    var potentialRoot = document.getElementById(this.getBlockRootId_(blockId));
    while (!this.isTreeRoot_(potentialRoot)) {
      potentialRoot = potentialRoot.parentNode;
    }
    return potentialRoot.id;
  },
  // Set focus to the tree containing the given block, and set the tree's
  // active desc to the root element of the given block.
  focusOnBlock: function(blockId) {
    // Invoke a digest cycle, in order to allow the ID of the newly-created
    // tree to be set in the DOM.
    var that = this;
    setTimeout(function() {
      var treeId = that.getTreeIdForBlock(blockId);
      document.getElementById(treeId).focus();
      that.setActiveDesc(that.getBlockRootId_(blockId), treeId);
    });
  },
  showBlockOptionsModal: function(block) {
    var that = this;
    var actionButtonsInfo = [];

    if (block.previousConnection) {
      actionButtonsInfo.push({
        action: function() {
          that.blockConnectionService.markConnection(block.previousConnection);
          that.focusOnBlock(block.id);
        },
        translationIdForText: 'MARK_SPOT_BEFORE'
      });
    }

    if (block.nextConnection) {
      actionButtonsInfo.push({
        action: function() {
          that.blockConnectionService.markConnection(block.nextConnection);
          that.focusOnBlock(block.id);
        },
        translationIdForText: 'MARK_SPOT_AFTER'
      });
    }

    if (this.blockConnectionService.canBeMovedToMarkedConnection(block)) {
      actionButtonsInfo.push({
        action: function() {
          var blockDescription = that.utilsService.getBlockDescription(block);
          var oldDestinationTreeId = that.getTreeIdForBlock(
              that.blockConnectionService.getMarkedConnectionSourceBlock().id);
          that.clearActiveDesc(oldDestinationTreeId);

          var newBlockId = that.blockConnectionService.attachToMarkedConnection(
              block);
          that.safelyRemoveBlock_(block, function() {
            block.dispose(false);
          }, true);

          // Invoke a digest cycle, so that the DOM settles.
          setTimeout(function() {
            that.focusOnBlock(newBlockId);
            var newDestinationTreeId = that.getTreeIdForBlock(newBlockId);

            if (newDestinationTreeId != oldDestinationTreeId) {
              // The tree ID for a moved block does not seem to behave
              // predictably. E.g. start with two separate groups of one block
              // each, add a link before the block in the second group, and
              // move the block in the first group to that link. The tree ID of
              // the resulting group ends up being the tree ID for the group
              // that was originally first, not second as might be expected.
              // Here, we double-check to ensure that all affected trees have
              // an active desc set.
              if (document.getElementById(oldDestinationTreeId)) {
                var activeDescId = that.getActiveDescId(oldDestinationTreeId);
                var activeDescTreeId = null;
                if (activeDescId) {
                  var oldDestinationBlock = that.getContainingBlock_(
                      document.getElementById(activeDescId));
                  activeDescTreeId = that.getTreeIdForBlock(
                      oldDestinationBlock);
                  if (activeDescTreeId != oldDestinationTreeId) {
                    that.clearActiveDesc(oldDestinationTreeId);
                  }
                }
                that.initActiveDesc(oldDestinationTreeId);
              }
            }

            that.notificationsService.speak(
                blockDescription + ' ' +
                Blockly.Msg.ATTACHED_BLOCK_TO_LINK_MSG +
                '. Now on attached block in workspace.');
          });
        },
        translationIdForText: 'MOVE_TO_MARKED_SPOT'
      });
    }

    actionButtonsInfo.push({
      action: function() {
        var blockDescription = that.utilsService.getBlockDescription(block);

        that.safelyRemoveBlock_(block, function() {
          block.dispose(true);
          that.audioService.playDeleteSound();
        }, false);

        setTimeout(function() {
          var message = blockDescription + ' deleted. ' + (
              that.utilsService.isWorkspaceEmpty() ?
              'Workspace is empty.' : 'Now on workspace.');
          that.notificationsService.speak(message);
        });
      },
      translationIdForText: 'DELETE'
    });

    this.blockOptionsModalService.showModal(actionButtonsInfo, function() {
      that.focusOnBlock(block.id);
    });
  },

  moveUpOneLevel_: function(treeId) {
    var activeDesc = document.getElementById(this.getActiveDescId(treeId));
    var nextNode = this.getParentLi_(activeDesc);
    if (nextNode) {
      this.setActiveDesc(nextNode.id, treeId);
    } else {
      this.audioService.playOopsSound();
    }
  },
  onKeypress: function(e, tree) {
    // TODO(sll): Instead of this, have a common ActiveContextService which
    // returns true if at least one modal is shown, and false otherwise.
    if (this.blockOptionsModalService.isModalShown() ||
        this.variableModalService.isModalShown()) {
      return;
    }

    var treeId = tree.id;
    var activeDesc = document.getElementById(this.getActiveDescId(treeId));
    if (!activeDesc) {
      // The underlying Blockly instance may have decided blocks needed to
      // be deleted. This is not necessarily an error, but needs to be repaired.
      this.initActiveDesc(treeId);
      activeDesc = document.getElementById(this.getActiveDescId(treeId));
    }

    if (e.altKey || e.ctrlKey) {
      // Do not intercept combinations such as Alt+Home.
      return;
    }

    if (document.activeElement.tagName == 'INPUT' ||
        document.activeElement.tagName == 'SELECT') {
      // For input fields, Esc, Enter, and Tab keystrokes are handled specially.
      if (e.keyCode == 9 || e.keyCode == 13 || e.keyCode == 27) {
        // Return the focus to the workspace tree containing the input field.
        document.getElementById(treeId).focus();

        // Note that Tab and Enter events stop propagating, this behavior is
        // handled on other listeners.
        if (e.keyCode == 27 || e.keyCode == 13) {
          e.preventDefault();
          e.stopPropagation();
        }
      }
    } else {
      // Outside an input field, Enter, Tab, Esc and navigation keys are all
      // recognized.
      if (e.keyCode == 13) {
        // Enter key. The user wants to interact with a button, interact with
        // an input field, or open the block options modal.
        // Algorithm to find the field: do a DFS through the children until
        // we find an INPUT, BUTTON or SELECT element (in which case we use it).
        // Truncate the search at child LI elements.
        e.stopPropagation();

        var found = false;
        var dfsStack = Array.from(activeDesc.children);
        while (dfsStack.length) {
          var currentNode = dfsStack.shift();
          if (currentNode.tagName == 'BUTTON') {
            currentNode.click();
            found = true;
            break;
          } else if (currentNode.tagName == 'INPUT') {
            currentNode.focus();
            currentNode.select();
            this.notificationsService.speak(
                'Type a value, then press Escape to exit');
            found = true;
            break;
          } else if (currentNode.tagName == 'SELECT') {
            currentNode.focus();
            found = true;
            return;
          } else if (currentNode.tagName == 'LI') {
            continue;
          }

          if (currentNode.children) {
            var reversedChildren = Array.from(currentNode.children).reverse();
            reversedChildren.forEach(function(childNode) {
              dfsStack.unshift(childNode);
            });
          }
        }

        // If we cannot find a field to interact with, we open the modal for
        // the current block instead.
        if (!found) {
          var block = this.getContainingBlock_(activeDesc);
          this.showBlockOptionsModal(block);
        }
      } else if (e.keyCode == 9) {
        // Tab key. The event is allowed to propagate through.
      } else if ([27, 35, 36, 37, 38, 39, 40].indexOf(e.keyCode) !== -1) {
        if (e.keyCode == 27 || e.keyCode == 37) {
          // Esc or left arrow key. Go up a level, if possible.
          this.moveUpOneLevel_(treeId);
        } else if (e.keyCode == 35) {
          // End key. Go to the last sibling in the subtree.
          var potentialFinalSibling = this.getFinalSiblingLi_(activeDesc);
          if (potentialFinalSibling) {
            this.setActiveDesc(potentialFinalSibling.id, treeId);
          }
        } else if (e.keyCode == 36) {
          // Home key. Go to the first sibling in the subtree.
          var potentialInitialSibling = this.getInitialSiblingLi_(activeDesc);
          if (potentialInitialSibling) {
            this.setActiveDesc(potentialInitialSibling.id, treeId);
          }
        } else if (e.keyCode == 38) {
          // Up arrow key. Go to the previous sibling, if possible.
          var potentialPrevSibling = this.getPreviousSiblingLi_(activeDesc);
          if (potentialPrevSibling) {
            this.setActiveDesc(potentialPrevSibling.id, treeId);
          } else {
            var statusMessage = 'Reached top of list.';
            if (this.getParentLi_(activeDesc)) {
              statusMessage += ' Press left to go to parent list.';
            }
            this.audioService.playOopsSound(statusMessage);
          }
        } else if (e.keyCode == 39) {
          // Right arrow key. Go down a level, if possible.
          var potentialFirstChild = this.getFirstChildLi_(activeDesc);
          if (potentialFirstChild) {
            this.setActiveDesc(potentialFirstChild.id, treeId);
          } else {
            this.audioService.playOopsSound();
          }
        } else if (e.keyCode == 40) {
          // Down arrow key. Go to the next sibling, if possible.
          var potentialNextSibling = this.getNextSiblingLi_(activeDesc);
          if (potentialNextSibling) {
            this.setActiveDesc(potentialNextSibling.id, treeId);
          } else {
            this.audioService.playOopsSound('Reached bottom of list.');
          }
        }

        e.preventDefault();
        e.stopPropagation();
      }
    }
  }
});
