/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

"use strict";

const { Cc, Ci, Cu } = require("chrome");

loader.lazyRequireGetter(
  this,
  "isShadowRoot",
  "devtools/shared/layout/utils",
  true
);
loader.lazyRequireGetter(
  this,
  "nodeFilterConstants",
  "devtools/shared/dom-node-filter-constants"
);
loader.lazyRequireGetter(
  this,
  "standardTreeWalkerFilter",
  "devtools/server/actors/inspector/utils",
  true
);

// SKIP_TO_* arguments are used with the DocumentWalker, driving the strategy to use if
// the starting node is incompatible with the filter function of the walker.
const SKIP_TO_PARENT = "SKIP_TO_PARENT";
const SKIP_TO_SIBLING = "SKIP_TO_SIBLING";

/**
 * Wrapper for inDeepTreeWalker.  Adds filtering to the traversal methods.
 * See inDeepTreeWalker for more information about the methods.
 *
 * @param {DOMNode} node
 * @param {Window} rootWin
 * @param {Object}
 *        - {Number} whatToShow
 *          See nodeFilterConstants / inIDeepTreeWalker for options.
 *        - {Function} filter
 *          A custom filter function Taking in a DOMNode and returning an Int. See
 *          WalkerActor.nodeFilter for an example.
 *        - {String} skipTo
 *          Either SKIP_TO_PARENT or SKIP_TO_SIBLING. If the provided node is not
 *          compatible with the filter function for this walker, try to find a compatible
 *          one either in the parents or in the siblings of the node.
 *        - {Boolean} showAnonymousContent
 *          Pass true to let the walker return and traverse anonymous content.
 *          When navigating host elements to which shadow DOM is attached, the light tree
 *          will be visible only to a walker with showAnonymousContent=false. The shadow
 *          tree will only be visible to a walker with showAnonymousContent=true.
 */
function DocumentWalker(
  node,
  rootWin,
  {
    whatToShow = nodeFilterConstants.SHOW_ALL,
    filter = standardTreeWalkerFilter,
    skipTo = SKIP_TO_PARENT,
    showAnonymousContent = true,
  } = {}
) {
  if (Cu.isDeadWrapper(rootWin) || !rootWin.location) {
    throw new Error("Got an invalid root window in DocumentWalker");
  }

  this.walker = Cc["@mozilla.org/inspector/deep-tree-walker;1"].createInstance(
    Ci.inIDeepTreeWalker
  );
  this.walker.showAnonymousContent = showAnonymousContent;
  this.walker.showSubDocuments = true;
  this.walker.showDocumentsAsNodes = true;
  this.walker.init(rootWin.document, whatToShow);
  this.filter = filter;

  // Make sure that the walker knows about the initial node (which could
  // be skipped due to a filter).
  this.walker.currentNode = this.getStartingNode(node, skipTo);
}

DocumentWalker.prototype = {
  get whatToShow() {
    return this.walker.whatToShow;
  },
  get currentNode() {
    return this.walker.currentNode;
  },
  set currentNode(val) {
    this.walker.currentNode = val;
  },

  parentNode: function() {
    if (isShadowRoot(this.currentNode)) {
      this.currentNode = this.currentNode.host;
      return this.currentNode;
    }

    const parentNode = this.currentNode.parentNode;
    // deep-tree-walker currently does not return shadowRoot elements as parentNodes.
    if (parentNode && isShadowRoot(parentNode)) {
      this.currentNode = parentNode;
      return this.currentNode;
    }
    return this.walker.parentNode();
  },

  nextNode: function() {
    const node = this.walker.currentNode;
    if (!node) {
      return null;
    }

    let nextNode = this.walker.nextNode();
    while (nextNode && this.isSkippedNode(nextNode)) {
      nextNode = this.walker.nextNode();
    }

    return nextNode;
  },

  firstChild: function() {
    const node = this.walker.currentNode;
    if (!node) {
      return null;
    }

    let firstChild = this.walker.firstChild();
    while (firstChild && this.isSkippedNode(firstChild)) {
      firstChild = this.walker.nextSibling();
    }

    return firstChild;
  },

  lastChild: function() {
    const node = this.walker.currentNode;
    if (!node) {
      return null;
    }

    let lastChild = this.walker.lastChild();
    while (lastChild && this.isSkippedNode(lastChild)) {
      lastChild = this.walker.previousSibling();
    }

    return lastChild;
  },

  previousSibling: function() {
    let node = this.walker.previousSibling();
    while (node && this.isSkippedNode(node)) {
      node = this.walker.previousSibling();
    }
    return node;
  },

  nextSibling: function() {
    let node = this.walker.nextSibling();
    while (node && this.isSkippedNode(node)) {
      node = this.walker.nextSibling();
    }
    return node;
  },

  getStartingNode: function(node, skipTo) {
    // Keep a reference on the starting node in case we can't find a node compatible with
    // the filter.
    const startingNode = node;

    if (skipTo === SKIP_TO_PARENT) {
      while (node && this.isSkippedNode(node)) {
        node = node.parentNode;
      }
    } else if (skipTo === SKIP_TO_SIBLING) {
      node = this.getClosestAcceptedSibling(node);
    }

    return node || startingNode;
  },

  /**
   * Loop on all of the provided node siblings until finding one that is compliant with
   * the filter function.
   */
  getClosestAcceptedSibling: function(node) {
    if (this.filter(node) === nodeFilterConstants.FILTER_ACCEPT) {
      // node is already valid, return immediately.
      return node;
    }

    // Loop on starting node siblings.
    let previous = node;
    let next = node;
    while (previous || next) {
      previous = previous?.previousSibling;
      next = next?.nextSibling;

      if (
        previous &&
        this.filter(previous) === nodeFilterConstants.FILTER_ACCEPT
      ) {
        // A valid node was found in the previous siblings of the node.
        return previous;
      }

      if (next && this.filter(next) === nodeFilterConstants.FILTER_ACCEPT) {
        // A valid node was found in the next siblings of the node.
        return next;
      }
    }

    return null;
  },

  isSkippedNode: function(node) {
    return this.filter(node) === nodeFilterConstants.FILTER_SKIP;
  },
};

exports.DocumentWalker = DocumentWalker;
exports.SKIP_TO_PARENT = SKIP_TO_PARENT;
exports.SKIP_TO_SIBLING = SKIP_TO_SIBLING;
