/* 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/. */

import { Assert } from "resource://testing-common/Assert.sys.mjs";

const MAX_TRIM_LENGTH = 100;

export const CommonUtils = {
  /**
   * Constant passed to getAccessible to indicate that it shouldn't fail if
   * there is no accessible.
   */
  DONOTFAIL_IF_NO_ACC: 1,

  /**
   * Constant passed to getAccessible to indicate that it shouldn't fail if it
   * does not support an interface.
   */
  DONOTFAIL_IF_NO_INTERFACE: 2,

  /**
   * nsIAccessibilityService service.
   */
  get accService() {
    if (!this._accService) {
      this._accService = Cc["@mozilla.org/accessibilityService;1"].getService(
        Ci.nsIAccessibilityService
      );
    }

    return this._accService;
  },

  clearAccService() {
    this._accService = null;
    Cu.forceGC();
  },

  /**
   * Adds an observer for an 'a11y-consumers-changed' event.
   */
  addAccConsumersChangedObserver() {
    const deferred = {};
    this._accConsumersChanged = new Promise(resolve => {
      deferred.resolve = resolve;
    });
    const observe = (subject, topic, data) => {
      Services.obs.removeObserver(observe, "a11y-consumers-changed");
      deferred.resolve(JSON.parse(data));
    };
    Services.obs.addObserver(observe, "a11y-consumers-changed");
  },

  /**
   * Returns a promise that resolves when 'a11y-consumers-changed' event is
   * fired.
   *
   * @return {Promise}
   *         event promise evaluating to event's data
   */
  observeAccConsumersChanged() {
    return this._accConsumersChanged;
  },

  /**
   * Adds an observer for an 'a11y-init-or-shutdown' event with a value of "1"
   * which indicates that an accessibility service is initialized in the current
   * process.
   */
  addAccServiceInitializedObserver() {
    const deferred = {};
    this._accServiceInitialized = new Promise((resolve, reject) => {
      deferred.resolve = resolve;
      deferred.reject = reject;
    });
    const observe = (subject, topic, data) => {
      if (data === "1") {
        Services.obs.removeObserver(observe, "a11y-init-or-shutdown");
        deferred.resolve();
      } else {
        deferred.reject("Accessibility service is shutdown unexpectedly.");
      }
    };
    Services.obs.addObserver(observe, "a11y-init-or-shutdown");
  },

  /**
   * Returns a promise that resolves when an accessibility service is
   * initialized in the current process. Otherwise (if the service is shutdown)
   * the promise is rejected.
   */
  observeAccServiceInitialized() {
    return this._accServiceInitialized;
  },

  /**
   * Adds an observer for an 'a11y-init-or-shutdown' event with a value of "0"
   * which indicates that an accessibility service is shutdown in the current
   * process.
   */
  addAccServiceShutdownObserver() {
    const deferred = {};
    this._accServiceShutdown = new Promise((resolve, reject) => {
      deferred.resolve = resolve;
      deferred.reject = reject;
    });
    const observe = (subject, topic, data) => {
      if (data === "0") {
        Services.obs.removeObserver(observe, "a11y-init-or-shutdown");
        deferred.resolve();
      } else {
        deferred.reject("Accessibility service is initialized unexpectedly.");
      }
    };
    Services.obs.addObserver(observe, "a11y-init-or-shutdown");
  },

  /**
   * Returns a promise that resolves when an accessibility service is shutdown
   * in the current process. Otherwise (if the service is initialized) the
   * promise is rejected.
   */
  observeAccServiceShutdown() {
    return this._accServiceShutdown;
  },

  /**
   * Extract DOMNode id from an accessible. If the accessible is in the remote
   * process, DOMNode is not present in parent process. However, if specified by
   * the author, DOMNode id will be attached to an accessible object.
   *
   * @param  {nsIAccessible} accessible  accessible
   * @return {String?}                   DOMNode id if available
   */
  getAccessibleDOMNodeID(accessible) {
    if (accessible instanceof Ci.nsIAccessibleDocument) {
      // If accessible is a document, trying to find its document body id.
      try {
        return accessible.DOMNode.body.id;
      } catch (e) {
        /* This only works if accessible is not a proxy. */
      }
    }
    try {
      return accessible.DOMNode.id;
    } catch (e) {
      /* This will fail if DOMNode is in different process. */
    }
    try {
      // When e10s is enabled, accessible will have an "id" property if its
      // corresponding DOMNode has an id. If accessible is a document, its "id"
      // property corresponds to the "id" of its body element.
      return accessible.id;
    } catch (e) {
      /* This will fail if accessible is not a proxy. */
    }

    return null;
  },

  getObjAddress(obj) {
    const exp = /native\s*@\s*(0x[a-f0-9]+)/g;
    const match = exp.exec(obj.toString());
    if (match) {
      return match[1];
    }

    return obj.toString();
  },

  getNodePrettyName(node) {
    try {
      let tag = "";
      if (node.nodeType == Node.DOCUMENT_NODE) {
        tag = "document";
      } else {
        tag = node.localName;
        if (node.nodeType == Node.ELEMENT_NODE && node.hasAttribute("id")) {
          tag += `@id="${node.getAttribute("id")}"`;
        }
      }

      return `"${tag} node", address: ${this.getObjAddress(node)}`;
    } catch (e) {
      return `" no node info "`;
    }
  },

  /**
   * Convert role to human readable string.
   */
  roleToString(role) {
    return this.accService.getStringRole(role);
  },

  /**
   * Shorten a long string if it exceeds MAX_TRIM_LENGTH.
   *
   * @param aString the string to shorten.
   *
   * @returns the shortened string.
   */
  shortenString(str) {
    if (str.length <= MAX_TRIM_LENGTH) {
      return str;
    }

    // Trim the string if its length is > MAX_TRIM_LENGTH characters.
    const trimOffset = MAX_TRIM_LENGTH / 2;

    return `${str.substring(0, trimOffset - 1)}…${str.substring(
      str.length - trimOffset,
      str.length
    )}`;
  },

  normalizeAccTreeObj(obj) {
    const key = Object.keys(obj)[0];
    const roleName = `ROLE_${key}`;
    if (roleName in Ci.nsIAccessibleRole) {
      return {
        role: Ci.nsIAccessibleRole[roleName],
        children: obj[key],
      };
    }

    return obj;
  },

  stringifyTree(obj) {
    let text = this.roleToString(obj.role) + ": [ ";
    if ("children" in obj) {
      for (let i = 0; i < obj.children.length; i++) {
        const c = this.normalizeAccTreeObj(obj.children[i]);
        text += this.stringifyTree(c);
        if (i < obj.children.length - 1) {
          text += ", ";
        }
      }
    }

    return `${text}] `;
  },

  /**
   * Return pretty name for identifier, it may be ID, DOM node or accessible.
   */
  prettyName(identifier) {
    if (identifier instanceof Array) {
      let msg = "";
      for (let idx = 0; idx < identifier.length; idx++) {
        if (msg != "") {
          msg += ", ";
        }

        msg += this.prettyName(identifier[idx]);
      }
      return msg;
    }

    if (identifier instanceof Ci.nsIAccessible) {
      const acc = this.getAccessible(identifier);
      const domID = this.getAccessibleDOMNodeID(acc);
      let msg = "[";
      try {
        if (Services.appinfo.browserTabsRemoteAutostart) {
          if (domID) {
            msg += `DOM node id: ${domID}, `;
          }
        } else {
          msg += `${this.getNodePrettyName(acc.DOMNode)}, `;
        }
        msg += `role: ${this.roleToString(acc.role)}`;
        if (acc.name) {
          msg += `, name: "${this.shortenString(acc.name)}"`;
        }
      } catch (e) {
        msg += "defunct";
      }

      if (acc) {
        msg += `, address: ${this.getObjAddress(acc)}`;
      }
      msg += "]";

      return msg;
    }

    if (Node.isInstance(identifier)) {
      return `[ ${this.getNodePrettyName(identifier)} ]`;
    }

    if (identifier && typeof identifier === "object") {
      const treeObj = this.normalizeAccTreeObj(identifier);
      if ("role" in treeObj) {
        return `{ ${this.stringifyTree(treeObj)} }`;
      }

      return JSON.stringify(identifier);
    }

    return ` "${identifier}" `;
  },

  /**
   * Return accessible for the given identifier (may be ID attribute or DOM
   * element or accessible object) or null.
   *
   * @param accOrElmOrID
   *        identifier to get an accessible implementing the given interfaces
   * @param aInterfaces
   *        [optional] the interface or an array interfaces to query it/them
   *        from obtained accessible
   * @param elmObj
   *        [optional] object to store DOM element which accessible is obtained
   *        for
   * @param doNotFailIf
   *        [optional] no error for special cases (see DONOTFAIL_IF_NO_ACC,
   *        DONOTFAIL_IF_NO_INTERFACE)
   * @param doc
   *        [optional] document for when accOrElmOrID is an ID.
   */
  getAccessible(accOrElmOrID, interfaces, elmObj, doNotFailIf, doc) {
    if (!accOrElmOrID) {
      return null;
    }

    let elm = null;
    if (accOrElmOrID instanceof Ci.nsIAccessible) {
      try {
        elm = accOrElmOrID.DOMNode;
      } catch (e) {}
    } else if (Node.isInstance(accOrElmOrID)) {
      elm = accOrElmOrID;
    } else {
      elm = doc.getElementById(accOrElmOrID);
      if (!elm) {
        Assert.ok(false, `Can't get DOM element for ${accOrElmOrID}`);
        return null;
      }
    }

    if (elmObj && typeof elmObj == "object") {
      elmObj.value = elm;
    }

    let acc = accOrElmOrID instanceof Ci.nsIAccessible ? accOrElmOrID : null;
    if (!acc) {
      try {
        acc = this.accService.getAccessibleFor(elm);
      } catch (e) {}

      if (!acc) {
        if (!(doNotFailIf & this.DONOTFAIL_IF_NO_ACC)) {
          Assert.ok(
            false,
            `Can't get accessible for ${this.prettyName(accOrElmOrID)}`
          );
        }

        return null;
      }
    }

    if (!interfaces) {
      return acc;
    }

    if (!(interfaces instanceof Array)) {
      interfaces = [interfaces];
    }

    for (let index = 0; index < interfaces.length; index++) {
      if (acc instanceof interfaces[index]) {
        continue;
      }

      try {
        acc.QueryInterface(interfaces[index]);
      } catch (e) {
        if (!(doNotFailIf & this.DONOTFAIL_IF_NO_INTERFACE)) {
          Assert.ok(
            false,
            `Can't query ${interfaces[index]} for ${accOrElmOrID}`
          );
        }

        return null;
      }
    }

    return acc;
  },

  /**
   * Return the DOM node by identifier (may be accessible, DOM node or ID).
   */
  getNode(accOrNodeOrID, doc) {
    if (!accOrNodeOrID) {
      return null;
    }

    if (Node.isInstance(accOrNodeOrID)) {
      return accOrNodeOrID;
    }

    if (accOrNodeOrID instanceof Ci.nsIAccessible) {
      return accOrNodeOrID.DOMNode;
    }

    const node = doc.getElementById(accOrNodeOrID);
    if (!node) {
      Assert.ok(false, `Can't get DOM element for ${accOrNodeOrID}`);
      return null;
    }

    return node;
  },

  /**
   * Return root accessible.
   *
   * @param  {DOMNode} doc
   *         Chrome document.
   *
   * @return {nsIAccessible}
   *         Accessible object for chrome window.
   */
  getRootAccessible(doc) {
    const acc = this.getAccessible(doc);
    return acc ? acc.rootDocument.QueryInterface(Ci.nsIAccessible) : null;
  },

  /**
   * Analogy of SimpleTest.is function used to compare objects.
   */
  isObject(obj, expectedObj, msg) {
    if (obj == expectedObj) {
      Assert.ok(true, msg);
      return;
    }

    Assert.ok(
      false,
      `${msg} - got "${this.prettyName(obj)}", expected "${this.prettyName(
        expectedObj
      )}"`
    );
  },
};
