/* 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 { KeyCodes } = require("resource://devtools/client/shared/keycodes.js");
const {
  flashElementOn,
  flashElementOff,
} = require("resource://devtools/client/inspector/markup/utils.js");

loader.lazyRequireGetter(
  this,
  "wrapMoveFocus",
  "resource://devtools/client/shared/focus.js",
  true
);

const DRAG_DROP_MIN_INITIAL_DISTANCE = 10;
const TYPES = {
  TEXT_CONTAINER: "textcontainer",
  ELEMENT_CONTAINER: "elementcontainer",
  READ_ONLY_CONTAINER: "readonlycontainer",
};

/**
 * The main structure for storing a document node in the markup
 * tree.  Manages creation of the editor for the node and
 * a <ul> for placing child elements, and expansion/collapsing
 * of the element.
 *
 * This should not be instantiated directly, instead use one of:
 *    MarkupReadOnlyContainer
 *    MarkupTextContainer
 *    MarkupElementContainer
 */
function MarkupContainer() {}

/**
 * Unique identifier used to set markup container node id.
 * @type {Number}
 */
let markupContainerID = 0;

MarkupContainer.prototype = {
  // Get the UndoStack from the MarkupView.
  get undo() {
    // undo is a lazy getter in the MarkupView.
    return this.markup.undo;
  },

  /*
   * Initialize the MarkupContainer.  Should be called while one
   * of the other contain classes is instantiated.
   *
   * @param  {MarkupView} markupView
   *         The markup view that owns this container.
   * @param  {NodeFront} node
   *         The node to display.
   * @param  {String} type
   *         The type of container to build. One of TYPES.TEXT_CONTAINER,
   *         TYPES.ELEMENT_CONTAINER, TYPES.READ_ONLY_CONTAINER
   */
  initialize(markupView, node, type) {
    this.markup = markupView;
    this.node = node;
    this.type = type;
    this.win = this.markup._frame.contentWindow;
    this.id = "treeitem-" + markupContainerID++;
    this.htmlElt = this.win.document.documentElement;

    this.buildMarkup();

    this.elt.container = this;

    this._onMouseDown = this._onMouseDown.bind(this);
    this._onClick = this._onClick.bind(this);
    this._onToggle = this._onToggle.bind(this);
    this._onKeyDown = this._onKeyDown.bind(this);
    this._eventListenersAbortController = new this.win.AbortController();

    // Binding event listeners
    const eventConfig = { signal: this._eventListenersAbortController.signal };
    this.elt.addEventListener("mousedown", this._onMouseDown, eventConfig);
    this.elt.addEventListener("click", this._onClick, eventConfig);
    this.elt.addEventListener("dblclick", this._onToggle, eventConfig);
    if (this.expander) {
      this.expander.addEventListener("click", this._onToggle, eventConfig);
    }

    // Marking the node as shown or hidden
    this.updateIsDisplayed();

    if (node.isShadowRoot) {
      Glean.devtoolsShadowdom.shadowRootDisplayed.set(true);
    }
  },

  buildMarkup() {
    this.elt = this.win.document.createElement("li");
    this.elt.classList.add("child", "collapsed");
    this.elt.setAttribute("role", "presentation");

    this.tagLine = this.win.document.createElement("div");
    this.tagLine.setAttribute("id", this.id);
    this.tagLine.classList.add("tag-line");
    this.tagLine.setAttribute("role", "treeitem");
    this.tagLine.setAttribute("aria-level", this.level);
    this.tagLine.setAttribute("aria-grabbed", this.isDragging);
    this.elt.appendChild(this.tagLine);

    this.mutationMarker = this.win.document.createElement("div");
    this.mutationMarker.classList.add("markup-tag-mutation-marker");
    this.mutationMarker.style.setProperty("--markup-level", this.level);
    this.tagLine.appendChild(this.mutationMarker);

    this.tagState = this.win.document.createElement("span");
    this.tagState.classList.add("tag-state");
    this.tagState.setAttribute("role", "presentation");
    this.tagLine.appendChild(this.tagState);

    if (this.type !== TYPES.TEXT_CONTAINER) {
      this.expander = this.win.document.createElement("span");
      this.expander.classList.add("theme-twisty", "expander");
      this.expander.setAttribute("role", "presentation");
      this.tagLine.appendChild(this.expander);
    }

    this.children = this.win.document.createElement("ul");
    this.children.classList.add("children");
    this.children.setAttribute("role", "group");
    this.elt.appendChild(this.children);
  },

  toString() {
    return "[MarkupContainer for " + this.node + "]";
  },

  isPreviewable() {
    if (this.node.tagName && !this.node.isPseudoElement) {
      const tagName = this.node.tagName.toLowerCase();
      const srcAttr = this.editor.getAttributeElement("src");
      const isImage = tagName === "img" && srcAttr;
      const isCanvas = tagName === "canvas";

      return isImage || isCanvas;
    }

    return false;
  },

  /**
   * Show whether the element is displayed or not
   * If an element has the attribute `display: none` or has been hidden with
   * the H key, it is not displayed (faded in markup view).
   * Otherwise, it is displayed.
   */
  updateIsDisplayed() {
    this.elt.classList.remove("not-displayed");
    if (!this.node.isDisplayed || this.node.hidden) {
      this.elt.classList.add("not-displayed");
    }
  },

  /**
   * True if the current node has children. The MarkupView
   * will set this attribute for the MarkupContainer.
   */
  _hasChildren: false,

  get hasChildren() {
    return this._hasChildren;
  },

  set hasChildren(value) {
    this._hasChildren = value;
    this.updateExpander();
  },

  /**
   * A list of all elements with tabindex that are not in container's children.
   */
  get focusableElms() {
    return [...this.tagLine.querySelectorAll("[tabindex]")];
  },

  /**
   * An indicator that the container internals are focusable.
   */
  get canFocus() {
    return this._canFocus;
  },

  /**
   * Toggle focusable state for container internals.
   */
  set canFocus(value) {
    if (this._canFocus === value) {
      return;
    }

    this._canFocus = value;

    if (value) {
      this.tagLine.addEventListener("keydown", this._onKeyDown, true);
      this.focusableElms.forEach(elm => elm.setAttribute("tabindex", "0"));
    } else {
      this.tagLine.removeEventListener("keydown", this._onKeyDown, true);
      // Exclude from tab order.
      this.focusableElms.forEach(elm => elm.setAttribute("tabindex", "-1"));
    }
  },

  /**
   * If conatiner and its contents are focusable, exclude them from tab order,
   * and, if necessary, remove focus.
   */
  clearFocus() {
    if (!this.canFocus) {
      return;
    }

    this.canFocus = false;
    const doc = this.markup.doc;

    if (!doc.activeElement || doc.activeElement === doc.body) {
      return;
    }

    let parent = doc.activeElement;

    while (parent && parent !== this.elt) {
      parent = parent.parentNode;
    }

    if (parent) {
      doc.activeElement.blur();
    }
  },

  /**
   * True if the current node can be expanded.
   */
  get canExpand() {
    return this._hasChildren && !this.node.inlineTextChild;
  },

  /**
   * True if this is the root <html> element and can't be collapsed.
   */
  get mustExpand() {
    return this.node._parent === this.markup.walker.rootNode;
  },

  /**
   * True if current node can be expanded and collapsed.
   */
  get showExpander() {
    return this.canExpand && !this.mustExpand;
  },

  updateExpander() {
    if (!this.expander) {
      return;
    }

    if (this.showExpander) {
      this.elt.classList.add("expandable");
      this.expander.style.visibility = "visible";
      // Update accessibility expanded state.
      this.tagLine.setAttribute("aria-expanded", this.expanded);
    } else {
      this.elt.classList.remove("expandable");
      this.expander.style.visibility = "hidden";
      // No need for accessible expanded state indicator when expander is not
      // shown.
      this.tagLine.removeAttribute("aria-expanded");
    }
  },

  /**
   * If current node has no children, ignore them. Otherwise, consider them a
   * group from the accessibility point of view.
   */
  setChildrenRole() {
    this.children.setAttribute(
      "role",
      this.hasChildren ? "group" : "presentation"
    );
  },

  /**
   * Set an appropriate DOM tree depth level for a node and its subtree.
   */
  updateLevel() {
    // ARIA level should already be set when the container markup is created.
    const currentLevel = this.tagLine.getAttribute("aria-level");
    const newLevel = this.level;
    if (currentLevel === newLevel) {
      // If level did not change, ignore this node and its subtree.
      return;
    }

    this.tagLine.setAttribute("aria-level", newLevel);
    const childContainers = this.getChildContainers();
    if (childContainers) {
      childContainers.forEach(container => container.updateLevel());
    }
  },

  /**
   * If the node has children, return the list of containers for all these
   * children.
   */
  getChildContainers() {
    if (!this.hasChildren) {
      return null;
    }

    return [...this.children.children]
      .filter(node => node.container)
      .map(node => node.container);
  },

  /**
   * True if the node has been visually expanded in the tree.
   */
  get expanded() {
    return !this.elt.classList.contains("collapsed");
  },

  setExpanded(value) {
    if (!this.expander) {
      return;
    }

    if (!this.canExpand) {
      value = false;
    }

    if (this.mustExpand) {
      value = true;
    }

    if (value && this.elt.classList.contains("collapsed")) {
      this.showCloseTagLine();

      this.elt.classList.remove("collapsed");
      this.expander.setAttribute("open", "");
      this.hovered = false;
      this.markup.emit("expanded");
    } else if (!value) {
      this.hideCloseTagLine();

      this.elt.classList.add("collapsed");
      this.expander.removeAttribute("open");
      this.markup.emit("collapsed");
    }

    if (this.showExpander) {
      this.tagLine.setAttribute("aria-expanded", this.expanded);
    }

    if (this.node.isShadowRoot) {
      Glean.devtoolsShadowdom.shadowRootExpanded.set(true);
    }
  },

  /**
   * Expanding a node means cloning its "inline" closing tag into a new
   * tag-line that the user can interact with and showing the children.
   */
  showCloseTagLine() {
    // Only element containers display a closing tag line. #document has no closing line.
    if (this.type !== TYPES.ELEMENT_CONTAINER) {
      return;
    }

    // Retrieve the closest .close node for this container.
    const closingTag = this.elt.querySelector(".close");
    if (!closingTag) {
      return;
    }

    // Create the closing tag-line element if not already created.
    if (!this.closeTagLine) {
      const line = this.markup.doc.createElement("div");
      line.classList.add("tag-line");
      // Closing tag is not important for accessibility.
      line.setAttribute("role", "presentation");

      const tagState = this.markup.doc.createElement("div");
      tagState.classList.add("tag-state");
      line.appendChild(tagState);

      line.appendChild(closingTag.cloneNode(true));

      flashElementOff(line);
      this.closeTagLine = line;
    }
    this.elt.appendChild(this.closeTagLine);
  },

  /**
   * Hide the closing tag-line element which should only be displayed when the container
   * is expanded.
   */
  hideCloseTagLine() {
    if (!this.closeTagLine) {
      return;
    }

    this.elt.removeChild(this.closeTagLine);
    this.closeTagLine = undefined;
  },

  parentContainer() {
    return this.elt.parentNode ? this.elt.parentNode.container : null;
  },

  /**
   * Determine tree depth level of a given node. This is used to specify ARIA
   * level for node tree items and to give them better semantic context.
   */
  get level() {
    let level = 1;
    let parent = this.node.parentNode();
    while (parent && parent !== this.markup.walker.rootNode) {
      level++;
      parent = parent.parentNode();
    }
    return level;
  },

  _isDragging: false,
  _dragStartY: 0,

  set isDragging(isDragging) {
    const rootElt = this.markup.getContainer(this.markup._rootNode).elt;
    this._isDragging = isDragging;
    this.markup.isDragging = isDragging;
    this.tagLine.setAttribute("aria-grabbed", isDragging);

    if (isDragging) {
      this.htmlElt.classList.add("dragging");
      this.elt.classList.add("dragging");
      this.markup.doc.body.classList.add("dragging");
      rootElt.setAttribute("aria-dropeffect", "move");
    } else {
      this.htmlElt.classList.remove("dragging");
      this.elt.classList.remove("dragging");
      this.markup.doc.body.classList.remove("dragging");
      rootElt.setAttribute("aria-dropeffect", "none");
    }
  },

  get isDragging() {
    return this._isDragging;
  },

  /**
   * Check if element is draggable.
   */
  isDraggable() {
    const tagName = this.node.tagName && this.node.tagName.toLowerCase();

    return (
      !this.node.isPseudoElement &&
      !this.node.isAnonymous &&
      !this.node.isDocumentElement &&
      tagName !== "body" &&
      tagName !== "head" &&
      this.win.getSelection().isCollapsed &&
      this.node.parentNode() &&
      this.node.parentNode().tagName !== null
    );
  },

  isSlotted() {
    return false;
  },

  _onKeyDown(event) {
    const { target, keyCode, shiftKey } = event;
    const isInput = this.markup._isInputOrTextarea(target);

    // Ignore all keystrokes that originated in editors except for when 'Tab' is
    // pressed.
    if (isInput && keyCode !== KeyCodes.DOM_VK_TAB) {
      return;
    }

    switch (keyCode) {
      case KeyCodes.DOM_VK_TAB:
        // Only handle 'Tab' if tabbable element is on the edge (first or last).
        if (isInput) {
          // Corresponding tabbable element is editor's next sibling.
          const next = wrapMoveFocus(
            this.focusableElms,
            target.nextSibling,
            shiftKey
          );
          if (next) {
            event.preventDefault();
            // Keep the editing state if possible.
            if (next._editable) {
              const e = this.markup.doc.createEvent("Event");
              e.initEvent(next._trigger, true, true);
              next.dispatchEvent(e);
            }
          }
        } else {
          const next = wrapMoveFocus(this.focusableElms, target, shiftKey);
          if (next) {
            event.preventDefault();
          }
        }
        break;
      case KeyCodes.DOM_VK_ESCAPE:
        this.clearFocus();
        this.markup.getContainer(this.markup._rootNode).elt.focus();
        if (this.isDragging) {
          // Escape when dragging is handled by markup view itself.
          return;
        }
        event.preventDefault();
        break;
      default:
        return;
    }
    event.stopPropagation();
  },

  _onMouseDown(event) {
    const { target, button, metaKey, ctrlKey } = event;
    const isLeftClick = button === 0;
    const isMiddleClick = button === 1;
    const isMetaClick = isLeftClick && (metaKey || ctrlKey);

    // The "show more nodes" button already has its onclick, so early return.
    if (target.nodeName === "button") {
      return;
    }

    // Bail out when clicking on arrow expanders to avoid selecting the row.
    if (target.classList.contains("expander")) {
      return;
    }

    // target is the MarkupContainer itself.
    this.hovered = false;
    this.markup.navigate(this);
    // Make container tabbable descendants tabbable and focus in.
    this.canFocus = true;
    this.focus({ fromMouseEvent: true });
    event.stopPropagation();

    // Preventing the default behavior will avoid the body to gain focus on
    // mouseup (through bubbling) when clicking on a non focusable node in the
    // line. So, if the click happened outside of a focusable element, do
    // prevent the default behavior, so that the tagname or textcontent gains
    // focus.
    if (!target.closest(".editor [tabindex]")) {
      event.preventDefault();
    }

    // Middle clicks will trigger the scroll lock feature to turn on.
    // The toolbox is normally responsible for calling preventDefault when
    // needed, but we prevent markup-view mousedown events from bubbling up (via
    // stopPropagation). So we have to preventDefault here as well in order to
    // avoid this issue.
    if (isMiddleClick) {
      event.preventDefault();
    }

    // Follow attribute links if middle or meta click.
    if (isMiddleClick || isMetaClick) {
      this._openAttributeLink(target.dataset.type, target.dataset.link);
      return;
    }

    // Start node drag & drop (if the mouse moved, see _onMouseMove).
    if (isLeftClick && this.isDraggable()) {
      this._isPreDragging = true;
      this._dragStartY = event.pageY;
      this.markup._draggedContainer = this;
    }
  },

  _onClick(event) {
    const { target } = event;
    if (target.nodeName !== "button") {
      return;
    }

    // We only care about handling click/keyboard activation for buttons inside
    // "link" attributes (e.g. the "select node" button)
    const closestLinkEl = target.closest("[data-link]");
    if (!closestLinkEl) {
      return;
    }

    this._openAttributeLink(
      closestLinkEl.dataset.type,
      closestLinkEl.dataset.link
    );
    event.stopPropagation();
  },

  /**
   * Open a "link" found in a node's attribute in the markup-view
   *
   * @param {String} type: A node-attribute-parser.js ATTRIBUTE_TYPES
   * @param {String} link: A "link" as returned by the `parseAttribute` function from
   *                 node-attribute-parser.js . This can be an actual URL, but could be
   *                 something else (e.g. an element id).
   */
  _openAttributeLink(type, link) {
    // Make container tabbable descendants not tabbable (by default).
    this.canFocus = false;
    this.markup.followAttributeLink(type, link);
  },

  /**
   * On mouse up, stop dragging.
   * This handler is called from the markup view, to reduce number of listeners.
   */
  async onMouseUp() {
    this._isPreDragging = false;
    this.markup._draggedContainer = null;

    if (this.isDragging) {
      this.cancelDragging();

      if (!this.markup.dropTargetNodes) {
        return;
      }

      const { nextSibling, parent } = this.markup.dropTargetNodes;
      const { walkerFront } = parent;
      await walkerFront.insertBefore(this.node, parent, nextSibling);
      this.markup.emit("drop-completed");
    }
  },

  /**
   * On mouse move, move the dragged element and indicate the drop target.
   * This handler is called from the markup view, to reduce number of listeners.
   */
  onMouseMove(event) {
    // If this is the first move after mousedown, only start dragging after the
    // mouse has travelled a few pixels and then indicate the start position.
    const initialDiff = Math.abs(event.pageY - this._dragStartY);
    if (this._isPreDragging && initialDiff >= DRAG_DROP_MIN_INITIAL_DISTANCE) {
      this._isPreDragging = false;
      this.isDragging = true;

      // If this is the last child, use the closing <div.tag-line> of parent as
      // indicator.
      const position =
        this.elt.nextElementSibling ||
        this.markup.getContainer(this.node.parentNode()).closeTagLine;
      this.markup.indicateDragTarget(position);
    }

    if (this.isDragging) {
      const x = 0;
      let y = event.pageY - this.win.scrollY;

      // Ensure we keep the dragged element within the markup view.
      if (y < 0) {
        y = 0;
      } else if (y >= this.markup.doc.body.offsetHeight - this.win.scrollY) {
        y = this.markup.doc.body.offsetHeight - this.win.scrollY - 1;
      }

      const diff = y - this._dragStartY + this.win.scrollY;
      this.elt.style.top = diff + "px";

      const el = this.markup.doc.elementFromPoint(x, y);
      this.markup.indicateDropTarget(el);
    }
  },

  cancelDragging() {
    if (!this.isDragging) {
      return;
    }

    this._isPreDragging = false;
    this.isDragging = false;
    this.elt.style.removeProperty("top");
  },

  /**
   * Temporarily flash the container to attract attention.
   * Used for markup mutations.
   */
  flashMutation() {
    if (!this.selected) {
      flashElementOn(this.tagState, {
        foregroundElt: this.editor.elt,
        backgroundClass: "theme-bg-contrast",
      });
      if (this._flashMutationTimer) {
        clearTimeout(this._flashMutationTimer);
        this._flashMutationTimer = null;
      }
      this._flashMutationTimer = setTimeout(() => {
        flashElementOff(this.tagState, {
          foregroundElt: this.editor.elt,
          backgroundClass: "theme-bg-contrast",
        });
      }, this.markup.CONTAINER_FLASHING_DURATION);
    }
  },

  _hovered: false,

  /**
   * Highlight the currently hovered tag + its closing tag if necessary
   * (that is if the tag is expanded)
   */
  set hovered(value) {
    this.tagState.classList.remove("flash-out");
    this._hovered = value;
    if (value) {
      if (!this.selected) {
        this.tagState.classList.add("tag-hover");
      }
      if (this.closeTagLine) {
        this.closeTagLine
          .querySelector(".tag-state")
          .classList.add("tag-hover");
      }
    } else {
      this.tagState.classList.remove("tag-hover");
      if (this.closeTagLine) {
        this.closeTagLine
          .querySelector(".tag-state")
          .classList.remove("tag-hover");
      }
    }
  },

  /**
   * True if the container is visible in the markup tree.
   */
  get visible() {
    return this.elt.getBoundingClientRect().height > 0;
  },

  /**
   * True if the container is currently selected.
   */
  _selected: false,

  get selected() {
    return this._selected;
  },

  set selected(value) {
    this.tagState.classList.remove("flash-out");
    this._selected = value;
    this.editor.selected = value;
    // Markup tree item should have accessible selected state.
    this.tagLine.setAttribute("aria-selected", value);
    if (this._selected) {
      const container = this.markup.getContainer(this.markup._rootNode);
      if (container) {
        container.elt.setAttribute("aria-activedescendant", this.id);
      }
      this.tagLine.setAttribute("selected", "");
      this.tagState.classList.add("theme-selected");
    } else {
      this.tagLine.removeAttribute("selected");
      this.tagState.classList.remove("theme-selected");
    }
  },

  /**
   * Update the container's editor to the current state of the
   * viewed node.
   */
  update(mutationBreakpoints) {
    if (this.node.pseudoClassLocks.length) {
      this.elt.classList.add("pseudoclass-locked");
    } else {
      this.elt.classList.remove("pseudoclass-locked");
    }

    if (mutationBreakpoints) {
      const allMutationsDisabled = Array.from(
        mutationBreakpoints.values()
      ).every(element => element === false);

      if (mutationBreakpoints.size > 0) {
        this.mutationMarker.classList.add("has-mutations");
        this.mutationMarker.classList.toggle(
          "mutation-breakpoint-disabled",
          allMutationsDisabled
        );
      } else {
        this.mutationMarker.classList.remove("has-mutations");
      }
    }

    this.updateIsDisplayed();

    if (this.editor.update) {
      this.editor.update();
    }
  },

  /**
   * Try to put keyboard focus on the current editor.
   *
   * @param {Object} options
   * @param {Boolean} options.fromMouseEvent: Set to true if this is called from a mouse event.
   */
  focus({ fromMouseEvent = false } = {}) {
    // Elements with tabindex of -1 are not focusable.
    const focusable = this.editor.elt.querySelector("[tabindex='0']");
    if (focusable) {
      if (fromMouseEvent) {
        // When focus is coming from a mouse event:
        // - prevent :focus-visible to be applied to the element
        // - don't scroll element into view, as this could change the horizontal scroll,
        //   and the element is already visible since the user clicked on it.
        Services.focus.setFocus(
          focusable,
          Services.focus.FLAG_NOSHOWRING | Services.focus.FLAG_NOSCROLL
        );
      } else {
        focusable.focus();
      }
    }
  },

  _onToggle(event) {
    event.stopPropagation();

    // Prevent the html tree from expanding when an event bubble, display or scrollable
    // node is clicked.
    if (
      event.target.dataset.event ||
      event.target.dataset.display ||
      event.target.dataset.scrollable
    ) {
      return;
    }

    this.expandContainer(event.altKey);
  },

  /**
   * Expands the markup container if it has children.
   *
   * @param  {Boolean} applyToDescendants
   *         Whether all descendants should also be expanded/collapsed
   */
  expandContainer(applyToDescendants) {
    if (this.hasChildren) {
      this.markup.setNodeExpanded(
        this.node,
        !this.expanded,
        applyToDescendants
      );
    }
  },

  /**
   * Get rid of event listeners and references, when the container is no longer
   * needed
   */
  destroy() {
    // Remove event listeners
    if (this._eventListenersAbortController) {
      this._eventListenersAbortController.abort();
    }
    this.tagLine.removeEventListener("keydown", this._onKeyDown, true);

    if (this.markup._draggedContainer === this) {
      this.markup._draggedContainer = null;
    }

    this.win = null;
    this.htmlElt = null;
    this._eventListenersAbortController = null;

    // Recursively destroy children containers
    let firstChild = this.children.firstChild;
    while (firstChild) {
      // Not all children of a container are containers themselves
      // ("show more nodes" button is one example)
      if (firstChild.container) {
        firstChild.container.destroy();
      }
      this.children.removeChild(firstChild);
      firstChild = this.children.firstChild;
    }

    this.editor.destroy();
  },
};

module.exports = MarkupContainer;
