/* 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 {
  FrontClassWithSpec,
  types,
  registerFront,
} = require("resource://devtools/shared/protocol.js");
const {
  nodeSpec,
  nodeListSpec,
} = require("resource://devtools/shared/specs/node.js");
const {
  SimpleStringFront,
} = require("resource://devtools/client/fronts/string.js");

loader.lazyRequireGetter(
  this,
  "nodeConstants",
  "resource://devtools/shared/dom-node-constants.js"
);

const { XPCOMUtils } = ChromeUtils.importESModule(
  "resource://gre/modules/XPCOMUtils.sys.mjs"
);

XPCOMUtils.defineLazyPreferenceGetter(
  this,
  "browserToolboxScope",
  "devtools.browsertoolbox.scope"
);

const BROWSER_TOOLBOX_SCOPE_EVERYTHING = "everything";

const HIDDEN_CLASS = "__fx-devtools-hide-shortcut__";

/**
 * Client side of a node list as returned by querySelectorAll()
 */
class NodeListFront extends FrontClassWithSpec(nodeListSpec) {
  marshallPool() {
    return this.getParent();
  }

  // Update the object given a form representation off the wire.
  form(json) {
    this.length = json.length;
  }

  item(index) {
    return super.item(index).then(response => {
      return response.node;
    });
  }

  items(start, end) {
    return super.items(start, end).then(response => {
      return response.nodes;
    });
  }
}

registerFront(NodeListFront);

/**
 * Convenience API for building a list of attribute modifications
 * for the `modifyAttributes` request.
 */
class AttributeModificationList {
  constructor(node) {
    this.node = node;
    this.modifications = [];
  }

  apply() {
    const ret = this.node.modifyAttributes(this.modifications);
    return ret;
  }

  destroy() {
    this.node = null;
    this.modification = null;
  }

  setAttributeNS(ns, name, value) {
    this.modifications.push({
      attributeNamespace: ns,
      attributeName: name,
      newValue: value,
    });
  }

  setAttribute(name, value) {
    this.setAttributeNS(undefined, name, value);
  }

  removeAttributeNS(ns, name) {
    this.setAttributeNS(ns, name, undefined);
  }

  removeAttribute(name) {
    this.setAttributeNS(undefined, name, undefined);
  }
}

/**
 * Client side of the node actor.
 *
 * Node fronts are strored in a tree that mirrors the DOM tree on the
 * server, but with a few key differences:
 *  - Not all children will be necessary loaded for each node.
 *  - The order of children isn't guaranteed to be the same as the DOM.
 * Children are stored in a doubly-linked list, to make addition/removal
 * and traversal quick.
 *
 * Due to the order/incompleteness of the child list, it is safe to use
 * the parent node from clients, but the `children` request should be used
 * to traverse children.
 */
class NodeFront extends FrontClassWithSpec(nodeSpec) {
  constructor(conn, targetFront, parentFront) {
    super(conn, targetFront, parentFront);
    // The parent node
    this._parent = null;
    // The first child of this node.
    this._child = null;
    // The next sibling of this node.
    this._next = null;
    // The previous sibling of this node.
    this._prev = null;
    // Store the flag to use it after destroy, where targetFront is set to null.
    this._hasParentProcessTarget = targetFront.isParentProcess;
  }

  /**
   * Destroy a node front.  The node must have been removed from the
   * ownership tree before this is called, unless the whole walker front
   * is being destroyed.
   */
  destroy() {
    super.destroy();
  }

  // Update the object given a form representation off the wire.
  form(form, ctx) {
    // backward-compatibility: shortValue indicates we are connected to old server
    if (form.shortValue) {
      // If the value is not complete, set nodeValue to null, it will be fetched
      // when calling getNodeValue()
      form.nodeValue = form.incompleteValue ? null : form.shortValue;
    }

    this.traits = form.traits || {};

    // Shallow copy of the form.  We could just store a reference, but
    // eventually we'll want to update some of the data.
    this._form = Object.assign({}, form);
    this._form.attrs = this._form.attrs ? this._form.attrs.slice() : [];

    if (form.parent) {
      // Get the owner actor for this actor (the walker), and find the
      // parent node of this actor from it, creating a standin node if
      // necessary.
      const owner = ctx.marshallPool();
      if (typeof owner.ensureDOMNodeFront === "function") {
        const parentNodeFront = owner.ensureDOMNodeFront(form.parent);
        this.reparent(parentNodeFront);
      }
    }

    if (form.host) {
      const owner = ctx.marshallPool();
      if (typeof owner.ensureDOMNodeFront === "function") {
        this.host = owner.ensureDOMNodeFront(form.host);
      }
    }

    if (form.inlineTextChild) {
      this.inlineTextChild = types
        .getType("domnode")
        .read(form.inlineTextChild, ctx);
    } else {
      this.inlineTextChild = undefined;
    }
  }

  /**
   * Returns the parent NodeFront for this NodeFront.
   */
  parentNode() {
    return this._parent;
  }

  /**
   * Returns the NodeFront corresponding to the parentNode of this NodeFront, or the
   * NodeFront corresponding to the host element for shadowRoot elements.
   */
  parentOrHost() {
    return this.isShadowRoot ? this.host : this._parent;
  }

  /**
   * Returns the owner DocumentElement|ShadowRootElement NodeFront for this NodeFront,
   * or null if such element can't be found.
   *
   * @returns {NodeFront|null}
   */
  getOwnerRootNodeFront() {
    let currentNode = this;
    while (currentNode) {
      if (
        currentNode.isShadowRoot ||
        currentNode.nodeType === Node.DOCUMENT_NODE
      ) {
        return currentNode;
      }

      currentNode = currentNode.parentNode();
    }

    return null;
  }

  /**
   * Process a mutation entry as returned from the walker's `getMutations`
   * request.  Only tries to handle changes of the node's contents
   * themselves (character data and attribute changes), the walker itself
   * will keep the ownership tree up to date.
   */
  updateMutation(change) {
    if (change.type === "attributes") {
      // We'll need to lazily reparse the attributes after this change.
      this._attrMap = undefined;

      // Update any already-existing attributes.
      let found = false;
      for (let i = 0; i < this.attributes.length; i++) {
        const attr = this.attributes[i];
        if (
          attr.name == change.attributeName &&
          attr.namespace == change.attributeNamespace
        ) {
          if (change.newValue !== null) {
            attr.value = change.newValue;
          } else {
            this.attributes.splice(i, 1);
          }
          found = true;
          break;
        }
      }
      // This is a new attribute. The null check is because of Bug 1192270,
      // in the case of a newly added then removed attribute
      if (!found && change.newValue !== null) {
        this.attributes.push({
          name: change.attributeName,
          namespace: change.attributeNamespace,
          value: change.newValue,
        });
      }
    } else if (change.type === "characterData") {
      this._form.nodeValue = change.newValue;
    } else if (change.type === "pseudoClassLock") {
      this._form.pseudoClassLocks = change.pseudoClassLocks;
    } else if (change.type === "events") {
      this._form.hasEventListeners = change.hasEventListeners;
    } else if (change.type === "mutationBreakpoint") {
      this._form.mutationBreakpoints = change.mutationBreakpoints;
    }
  }

  // Some accessors to make NodeFront feel more like a Node

  get id() {
    return this.getAttribute("id");
  }

  get nodeType() {
    return this._form.nodeType;
  }
  get namespaceURI() {
    return this._form.namespaceURI;
  }
  get nodeName() {
    return this._form.nodeName;
  }
  get displayName() {
    // @backward-compat { version 147 } When 147 reaches release, we can remove this 'if' block.
    // The form's displayName will be populated correctly for pseudo elements.
    if (
      this.isPseudoElement &&
      !this.traits.hasPseudoElementNameInDisplayName
    ) {
      if (this.isMarkerPseudoElement) {
        return "::marker";
      }
      if (this.isBeforePseudoElement) {
        return "::before";
      }
      if (this.isAfterPseudoElement) {
        return "::after";
      }
    }

    return this._form.displayName;
  }
  get doctypeString() {
    return (
      "<!DOCTYPE " +
      this._form.name +
      (this._form.publicId ? ' PUBLIC "' + this._form.publicId + '"' : "") +
      (this._form.systemId ? ' "' + this._form.systemId + '"' : "") +
      ">"
    );
  }

  get baseURI() {
    return this._form.baseURI;
  }

  get browsingContextID() {
    return this._form.browsingContextID;
  }

  get className() {
    return this.getAttribute("class") || "";
  }

  // Check if the node has children but the current DevTools session is unable
  // to retrieve them.
  // Typically: a <frame> or <browser> element which loads a document in another
  // process, but the toolbox' configuration prevents to inspect it (eg the
  // parent-process only Browser Toolbox).
  get childrenUnavailable() {
    return (
      // If form.useChildTargetToFetchChildren is true, it means the node HAS
      // children in another target.
      // Note: useChildTargetToFetchChildren might be undefined, force
      // conversion to boolean. See Bug 1783613 to try and improve this.
      !!this._form.useChildTargetToFetchChildren &&
      // But if useChildTargetToFetchChildren is false, it means the client
      // configuration prevents from displaying such children.
      // This is the only case where children are considered as unavailable:
      // they exist, but can't be retrieved by configuration.
      !this.useChildTargetToFetchChildren
    );
  }
  get hasChildren() {
    return this.numChildren > 0;
  }
  get numChildren() {
    if (this.childrenUnavailable) {
      return 0;
    }

    return this._form.numChildren;
  }
  get useChildTargetToFetchChildren() {
    if (
      this._hasParentProcessTarget &&
      browserToolboxScope != BROWSER_TOOLBOX_SCOPE_EVERYTHING
    ) {
      return false;
    }

    return !!this._form.useChildTargetToFetchChildren;
  }
  get hasEventListeners() {
    return this._form.hasEventListeners;
  }

  get isMarkerPseudoElement() {
    return this._form.isMarkerPseudoElement;
  }
  get isBeforePseudoElement() {
    return this._form.isBeforePseudoElement;
  }
  get isAfterPseudoElement() {
    return this._form.isAfterPseudoElement;
  }
  get isPseudoElement() {
    // @backward-compat { version 147 } When 147 reaches release, we can remove this 'if' block,
    // as well as the isXXXPseudoElement getters.
    // The form isPseudoElement property will be populated correctly.
    if (!this.traits.hasPseudoElementNameInDisplayName) {
      return (
        this.isBeforePseudoElement ||
        this.isAfterPseudoElement ||
        this.isMarkerPseudoElement
      );
    }

    return this._form.isPseudoElement;
  }
  get isNativeAnonymous() {
    return this._form.isNativeAnonymous;
  }
  get isInHTMLDocument() {
    return this._form.isInHTMLDocument;
  }
  get tagName() {
    return this.nodeType === nodeConstants.ELEMENT_NODE ? this.nodeName : null;
  }

  get isDocumentElement() {
    return !!this._form.isDocumentElement;
  }

  get isTopLevelDocument() {
    return this._form.isTopLevelDocument;
  }

  get isShadowRoot() {
    return this._form.isShadowRoot;
  }

  get shadowRootMode() {
    return this._form.shadowRootMode;
  }

  get isShadowHost() {
    return this._form.isShadowHost;
  }

  get customElementLocation() {
    return this._form.customElementLocation;
  }

  get isDirectShadowHostChild() {
    return this._form.isDirectShadowHostChild;
  }

  // doctype properties
  get name() {
    return this._form.name;
  }
  get publicId() {
    return this._form.publicId;
  }
  get systemId() {
    return this._form.systemId;
  }

  getAttribute(name) {
    const attr = this._getAttribute(name);
    return attr ? attr.value : null;
  }
  hasAttribute(name) {
    this._cacheAttributes();
    return name in this._attrMap;
  }

  get hidden() {
    const cls = this.getAttribute("class");
    return cls && cls.indexOf(HIDDEN_CLASS) > -1;
  }

  get attributes() {
    return this._form.attrs;
  }

  get mutationBreakpoints() {
    return this._form.mutationBreakpoints;
  }

  get pseudoClassLocks() {
    return this._form.pseudoClassLocks || [];
  }
  hasPseudoClassLock(pseudo) {
    return this.pseudoClassLocks.some(locked => locked === pseudo);
  }

  get displayType() {
    return this._form.displayType;
  }

  get isDisplayed() {
    return this._form.isDisplayed;
  }

  get isScrollable() {
    return this._form.isScrollable;
  }

  get causesOverflow() {
    return this._form.causesOverflow;
  }

  get containerType() {
    return this._form.containerType;
  }

  get anchorName() {
    return this._form.anchorName;
  }

  get isTreeDisplayed() {
    let parent = this;
    while (parent) {
      if (!parent.isDisplayed) {
        return false;
      }
      parent = parent.parentNode();
    }
    return true;
  }

  get inspectorFront() {
    return this.parentFront.parentFront;
  }

  get walkerFront() {
    return this.parentFront;
  }

  getNodeValue() {
    // backward-compatibility: if nodevalue is null and shortValue is defined, the actual
    // value of the node needs to be fetched on the server.
    if (this._form.nodeValue === null && this._form.shortValue) {
      return super.getNodeValue();
    }

    const str = this._form.nodeValue || "";
    return Promise.resolve(new SimpleStringFront(str));
  }

  /**
   * Return a new AttributeModificationList for this node.
   */
  startModifyingAttributes() {
    return new AttributeModificationList(this);
  }

  _cacheAttributes() {
    if (typeof this._attrMap != "undefined") {
      return;
    }
    this._attrMap = {};
    for (const attr of this.attributes) {
      this._attrMap[attr.name] = attr;
    }
  }

  _getAttribute(name) {
    this._cacheAttributes();
    return this._attrMap[name] || undefined;
  }

  /**
   * Set this node's parent.  Note that the children saved in
   * this tree are unordered and incomplete, so shouldn't be used
   * instead of a `children` request.
   */
  reparent(parent) {
    if (this._parent === parent) {
      return;
    }

    if (this._parent && this._parent._child === this) {
      this._parent._child = this._next;
    }
    if (this._prev) {
      this._prev._next = this._next;
    }
    if (this._next) {
      this._next._prev = this._prev;
    }
    this._next = null;
    this._prev = null;
    this._parent = parent;
    if (!parent) {
      // Subtree is disconnected, we're done
      return;
    }
    this._next = parent._child;
    if (this._next) {
      this._next._prev = this;
    }
    parent._child = this;
  }

  /**
   * Return all the known children of this node.
   */
  treeChildren() {
    const ret = [];
    for (let child = this._child; child != null; child = child._next) {
      ret.push(child);
    }
    return ret;
  }

  /**
   * Do we use a local target?
   * Useful to know if a rawNode is available or not.
   *
   * This will, one day, be removed. External code should
   * not need to know if the target is remote or not.
   */
  isLocalToBeDeprecated() {
    return !!this.conn._transport._serverConnection;
  }

  /**
   * Get a Node for the given node front.  This only works locally,
   * and is only intended as a stopgap during the transition to the remote
   * protocol.  If you depend on this you're likely to break soon.
   */
  rawNode() {
    if (!this.isLocalToBeDeprecated()) {
      console.warn("Tried to use rawNode on a remote connection.");
      return null;
    }
    const {
      DevToolsServer,
    } = require("resource://devtools/server/devtools-server.js");
    const actor = DevToolsServer.searchAllConnectionsForActor(this.actorID);
    if (!actor) {
      // Can happen if we try to get the raw node for an already-expired
      // actor.
      return null;
    }
    return actor.rawNode;
  }

  async connectToFrame() {
    if (!this.useChildTargetToFetchChildren) {
      console.warn("Tried to open connection to an invalid frame.");
      return null;
    }
    if (
      this._childBrowsingContextTarget &&
      !this._childBrowsingContextTarget.isDestroyed()
    ) {
      return this._childBrowsingContextTarget;
    }

    // Get the target for this frame element
    this._childBrowsingContextTarget =
      await this.targetFront.getWindowGlobalTarget(
        this._form.browsingContextID
      );

    // Bug 1776250: When the target is destroyed, we need to easily find the
    // parent node front so that we can update its frontend container in the
    // markup-view.
    this._childBrowsingContextTarget.setParentNodeFront(this);

    return this._childBrowsingContextTarget;
  }
}

exports.NodeFront = NodeFront;
registerFront(NodeFront);
