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

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  accessibility:
    "chrome://remote/content/shared/webdriver/Accessibility.sys.mjs",
  assertTargetInViewPort:
    "chrome://remote/content/shared/webdriver/Actions.sys.mjs",
  atom: "chrome://remote/content/marionette/atom.sys.mjs",
  dom: "chrome://remote/content/shared/DOM.sys.mjs",
  error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs",
  evaluate: "chrome://remote/content/marionette/evaluate.sys.mjs",
  event: "chrome://remote/content/shared/webdriver/Event.sys.mjs",
  interaction: "chrome://remote/content/marionette/interaction.sys.mjs",
  json: "chrome://remote/content/marionette/json.sys.mjs",
  Log: "chrome://remote/content/shared/Log.sys.mjs",
  sandbox: "chrome://remote/content/marionette/evaluate.sys.mjs",
  Sandboxes: "chrome://remote/content/marionette/evaluate.sys.mjs",
});

ChromeUtils.defineLazyGetter(lazy, "logger", () =>
  lazy.Log.get(lazy.Log.TYPES.MARIONETTE)
);

export class MarionetteCommandsChild extends JSWindowActorChild {
  #processActor;

  constructor() {
    super();

    this.#processActor = ChromeUtils.domProcessChild.getActor(
      "WebDriverProcessData"
    );

    // sandbox storage and name of the current sandbox
    this.sandboxes = new lazy.Sandboxes(() => this.document.defaultView);
  }

  get innerWindowId() {
    return this.manager.innerWindowId;
  }

  actorCreated() {
    lazy.logger.trace(
      `[${this.browsingContext.id}] MarionetteCommands actor created ` +
        `for window id ${this.innerWindowId}`
    );
  }

  didDestroy() {
    lazy.logger.trace(
      `[${this.browsingContext.id}] MarionetteCommands actor destroyed ` +
        `for window id ${this.innerWindowId}`
    );
  }

  #assertInViewPort(options) {
    const { target } = options;

    return lazy.assertTargetInViewPort(target, this.contentWindow);
  }

  async #dispatchEvent(options) {
    const { eventName, details } = options;
    const win = this.contentWindow;

    const windowUtils = win.windowUtils;
    const microTaskLevel = windowUtils.microTaskLevel;
    // Since we're being called as a webidl callback,
    // CallbackObjectBase::CallSetup::CallSetup has increased the microtask
    // level. Undo that temporarily so that microtask handling works closer
    // the way it would work when dispatching events natively.
    windowUtils.microTaskLevel = 0;
    try {
      switch (eventName) {
        case "synthesizeKeyDown":
          lazy.event.sendKeyDown(details.eventData, win);
          break;
        case "synthesizeKeyUp":
          lazy.event.sendKeyUp(details.eventData, win);
          break;
        case "synthesizeMouseAtPoint":
          await lazy.event.synthesizeMouseAtPoint(
            details.x,
            details.y,
            details.eventData,
            win
          );
          break;
        case "synthesizeTouchAtPoint":
          await lazy.event.synthesizeTouchAtPoint(details.eventData, win);
          break;
        case "synthesizeWheelAtPoint":
          await lazy.event.synthesizeWheelAtPoint(
            details.x,
            details.y,
            details.eventData,
            win
          );
          break;
        default:
          throw new Error(
            `${eventName} is not a supported event dispatch method`
          );
      }
    } catch (e) {
      if (e.message.includes("NS_ERROR_FAILURE")) {
        // Event dispatch failed. Re-throwing as AbortError to allow retrying
        // to dispatch the event.
        throw new DOMException(
          `Failed to dispatch event "${eventName}": ${e.message}`,
          "AbortError"
        );
      }

      throw e;
    } finally {
      windowUtils.microTaskLevel = microTaskLevel;
    }
  }

  async #finalizeAction() {
    if (!this.contentWindow) {
      return;
    }

    // Terminate the current wheel transaction if there is one. Wheel
    // transactions should not live longer than a single action chain.
    await ChromeUtils.endWheelTransaction(this.contentWindow);

    // Wait until the main thread has processed all already queued-up
    // runnables to ensure that dispatched input events have been handled.
    await new Promise(resolve => Services.tm.dispatchToMainThread(resolve));
  }

  #getClientRects(options, _context) {
    const { elem } = options;

    return elem.getClientRects();
  }

  #getInViewCentrePoint(options) {
    const { rect } = options;

    return lazy.dom.getInViewCentrePoint(rect, this.contentWindow);
  }

  #toBrowserWindowCoordinates(options, _context) {
    const { position } = options;

    return this.contentWindow.windowUtils.toTopLevelWidgetRect(
      position[0],
      position[1],
      0,
      0
    );
  }

  // eslint-disable-next-line complexity
  async receiveMessage(msg) {
    if (!this.contentWindow) {
      throw new DOMException("Actor is no longer active", "InactiveActor");
    }

    try {
      let result;
      let waitForNextTick = false;

      const { name, data: serializedData } = msg;

      const data = lazy.json.deserialize(
        serializedData,
        this.#processActor.getNodeCache(),
        this.contentWindow.browsingContext
      );

      switch (name) {
        case "MarionetteCommandsParent:_assertInViewPort":
          result = this.#assertInViewPort(data);
          break;
        case "MarionetteCommandsParent:_dispatchEvent":
          await this.#dispatchEvent(data);
          waitForNextTick = true;
          break;
        case "MarionetteCommandsParent:_getClientRects":
          result = this.#getClientRects(data);
          break;
        case "MarionetteCommandsParent:_getInViewCentrePoint":
          result = this.#getInViewCentrePoint(data);
          break;
        case "MarionetteCommandsParent:_finalizeAction":
          await this.#finalizeAction();
          break;
        case "MarionetteCommandsParent:_toBrowserWindowCoordinates":
          result = this.#toBrowserWindowCoordinates(data);
          break;
        case "MarionetteCommandsParent:clearElement":
          this.clearElement(data);
          waitForNextTick = true;
          break;
        case "MarionetteCommandsParent:clickElement":
          result = await this.clickElement(data);
          waitForNextTick = true;
          break;
        case "MarionetteCommandsParent:executeScript":
          result = await this.executeScript(data);
          waitForNextTick = true;
          break;
        case "MarionetteCommandsParent:findElement":
          result = await this.findElement(data);
          break;
        case "MarionetteCommandsParent:findElements":
          result = await this.findElements(data);
          break;
        case "MarionetteCommandsParent:generateTestReport":
          result = await this.generateTestReport(data);
          break;
        case "MarionetteCommandsParent:getAccessibilityPropertiesForAccessibilityNode":
          result =
            await this.getAccessibilityPropertiesForAccessibilityNode(data);
          break;
        case "MarionetteCommandsParent:getAccessibilityPropertiesForElement":
          result = await this.getAccessibilityPropertiesForElement(data);
          break;
        case "MarionetteCommandsParent:getActiveElement":
          result = await this.getActiveElement();
          break;
        case "MarionetteCommandsParent:getComputedLabel":
          result = await this.getComputedLabel(data);
          break;
        case "MarionetteCommandsParent:getComputedRole":
          result = await this.getComputedRole(data);
          break;
        case "MarionetteCommandsParent:getElementAttribute":
          result = await this.getElementAttribute(data);
          break;
        case "MarionetteCommandsParent:getElementProperty":
          result = await this.getElementProperty(data);
          break;
        case "MarionetteCommandsParent:getElementRect":
          result = await this.getElementRect(data);
          break;
        case "MarionetteCommandsParent:getElementTagName":
          result = await this.getElementTagName(data);
          break;
        case "MarionetteCommandsParent:getElementText":
          result = await this.getElementText(data);
          break;
        case "MarionetteCommandsParent:getElementValueOfCssProperty":
          result = await this.getElementValueOfCssProperty(data);
          break;
        case "MarionetteCommandsParent:getPageSource":
          result = await this.getPageSource();
          break;
        case "MarionetteCommandsParent:getScreenshotRect":
          result = await this.getScreenshotRect(data);
          break;
        case "MarionetteCommandsParent:getShadowRoot":
          result = await this.getShadowRoot(data);
          break;
        case "MarionetteCommandsParent:isElementDisplayed":
          result = await this.isElementDisplayed(data);
          break;
        case "MarionetteCommandsParent:isElementEnabled":
          result = await this.isElementEnabled(data);
          break;
        case "MarionetteCommandsParent:isElementSelected":
          result = await this.isElementSelected(data);
          break;
        case "MarionetteCommandsParent:sendKeysToElement":
          result = await this.sendKeysToElement(data);
          waitForNextTick = true;
          break;
        case "MarionetteCommandsParent:switchToFrame":
          result = await this.switchToFrame(data);
          waitForNextTick = true;
          break;
        case "MarionetteCommandsParent:switchToParentFrame":
          result = await this.switchToParentFrame();
          waitForNextTick = true;
          break;
      }

      // Inform the content process that the command has completed. It allows
      // it to process async follow-up tasks before the reply is sent.
      if (waitForNextTick) {
        await new Promise(resolve => Services.tm.dispatchToMainThread(resolve));
      }

      const { seenNodeIds, serializedValue, hasSerializedWindows } =
        lazy.json.clone(result, this.#processActor.getNodeCache());

      // Because in WebDriver classic nodes can only be returned from the same
      // browsing context, we only need the seen unique ids as flat array.
      return {
        seenNodeIds: [...seenNodeIds.values()].flat(),
        serializedValue,
        hasSerializedWindows,
      };
    } catch (e) {
      if (lazy.error.isWebDriverError(e)) {
        // If it's a WebDriver error always serialize it because it could
        // contain objects that are not serializable by default.
        return { error: e.toJSON(), isWebDriverError: true };
      }
      return { error: e, isWebDriverError: false };
    }
  }

  // Implementation of WebDriver commands

  /**
   * Clear the text of an element.
   *
   * @param {object} options
   * @param {Element} options.elem
   */
  clearElement(options = {}) {
    const { elem } = options;

    lazy.interaction.clearElement(elem);
  }

  /**
   * Click an element.
   */
  async clickElement(options = {}) {
    const { capabilities, elem } = options;

    return lazy.interaction.clickElement(
      elem,
      capabilities["moz:accessibilityChecks"],
      capabilities["moz:webdriverClick"]
    );
  }

  /**
   * Executes a JavaScript function.
   */
  async executeScript(options = {}) {
    const { args, opts = {}, script } = options;

    let sb;
    if (opts.sandboxName) {
      sb = this.sandboxes.get(opts.sandboxName, opts.newSandbox);
    } else {
      sb = lazy.sandbox.createMutable(this.document.defaultView);
    }

    return lazy.evaluate.sandbox(sb, script, args, opts);
  }

  /**
   * Find an element in the current browsing context's document using the
   * given search strategy.
   *
   * @param {object=} options
   * @param {string} options.strategy
   * @param {string} options.selector
   * @param {object} options.opts
   * @param {Element} options.opts.startNode
   */
  async findElement(options = {}) {
    const { strategy, selector, opts } = options;

    opts.all = false;

    const container = { frame: this.document.defaultView };
    return lazy.dom.find(container, strategy, selector, opts);
  }

  /**
   * Find elements in the current browsing context's document using the
   * given search strategy.
   *
   * @param {object=} options
   * @param {string} options.strategy
   * @param {string} options.selector
   * @param {object} options.opts
   * @param {Element} options.opts.startNode
   */
  async findElements(options = {}) {
    const { strategy, selector, opts } = options;

    opts.all = true;

    const container = { frame: this.document.defaultView };
    return lazy.dom.find(container, strategy, selector, opts);
  }

  /**
   * Generates and sends a test report to be observed by any registered reporting observers
   */
  async generateTestReport(options = {}) {
    const { message, group } = options;
    return this.browsingContext.window.TestReportGenerator.generateReport({
      message,
      group,
    });
  }

  /**
   * Return the properties for the accessibility node with the given id.
   */
  async getAccessibilityPropertiesForAccessibilityNode(options = {}) {
    const { id } = options;

    return lazy.accessibility.getAccessibilityPropertiesForAccessibilityNode(
      id
    );
  }

  /**
   * Return the accessibility properties for a given element.
   */
  async getAccessibilityPropertiesForElement(options = {}) {
    const { elem } = options;

    return lazy.accessibility.getAccessibilityPropertiesForElement(elem);
  }

  /**
   * Return the active element in the document.
   */
  async getActiveElement() {
    let elem = this.document.activeElement;
    if (!elem) {
      throw new lazy.error.NoSuchElementError();
    }

    return elem;
  }

  /**
   * Return the accessible label for a given element.
   */
  async getComputedLabel(options = {}) {
    const { elem } = options;

    return lazy.accessibility.getAccessibleName(elem);
  }

  /**
   * Return the accessible role for a given element.
   */
  async getComputedRole(options = {}) {
    const { elem } = options;

    return lazy.accessibility.getComputedRole(elem);
  }

  /**
   * Get the value of an attribute for the given element.
   */
  async getElementAttribute(options = {}) {
    const { name, elem } = options;

    if (lazy.dom.isBooleanAttribute(elem, name)) {
      if (elem.hasAttribute(name)) {
        return "true";
      }
      return null;
    }
    return elem.getAttribute(name);
  }

  /**
   * Get the value of a property for the given element.
   */
  async getElementProperty(options = {}) {
    const { name, elem } = options;

    // Waive Xrays to get unfiltered access to the untrusted element.
    const el = Cu.waiveXrays(elem);
    return typeof el[name] != "undefined" ? el[name] : null;
  }

  /**
   * Get the position and dimensions of the element.
   */
  async getElementRect(options = {}) {
    const { elem } = options;
    return lazy.dom.getElementRect(elem);
  }

  /**
   * Get the tagName for the given element.
   */
  async getElementTagName(options = {}) {
    const { elem } = options;

    return elem.tagName.toLowerCase();
  }

  /**
   * Get the text content for the given element.
   */
  async getElementText(options = {}) {
    const { elem } = options;

    try {
      return await lazy.atom.getVisibleText(elem, this.document.defaultView);
    } catch (e) {
      lazy.logger.warn(`Atom getVisibleText failed: "${e.message}"`);

      // Fallback in case the atom implementation is broken.
      // As known so far this only happens for XML documents (bug 1794099).
      return elem.textContent;
    }
  }

  /**
   * Get the value of a css property for the given element.
   */
  async getElementValueOfCssProperty(options = {}) {
    const { name, elem } = options;

    const style = this.document.defaultView.getComputedStyle(elem);
    return style.getPropertyValue(name);
  }

  /**
   * Get the source of the current browsing context's document.
   */
  async getPageSource() {
    return this.document.documentElement.outerHTML;
  }

  /**
   * Returns the rect of the element to screenshot.
   *
   * Because the screen capture takes place in the parent process the dimensions
   * for the screenshot have to be determined in the appropriate child process.
   *
   * Also it takes care of scrolling an element into view if requested.
   *
   * @param {object} options
   * @param {Element} options.elem
   *     Optional element to take a screenshot of.
   * @param {boolean=} options.full
   *     True to take a screenshot of the entire document element.
   *     Defaults to true.
   * @param {boolean=} options.scroll
   *     When <var>elem</var> is given, scroll it into view.
   *     Defaults to true.
   *
   * @returns {DOMRect}
   *     The area to take a snapshot from.
   */
  async getScreenshotRect(options = {}) {
    const { elem, full = true, scroll = true } = options;
    const win = elem
      ? this.document.defaultView
      : this.browsingContext.top.window;

    let rect;

    if (elem) {
      // Throw an error if 'full' is passed with an element, as this is an invalid state.
      if (full) {
        throw new Error(
          "Full screenshot is not supported when an element is provided."
        );
      }

      if (scroll) {
        lazy.dom.scrollIntoView(elem);
      }

      rect = lazy.dom.getElementRect(elem);

      // Calculate the intersection between the element's bounding client rect
      // and the visual viewport to draw a bounding box from the framebuffer.
      // Spec: https://w3c.github.io/webdriver/#dfn-draw-a-bounding-box-from-the-framebuffer
      const viewport = win.visualViewport;

      const viewportX = viewport.pageLeft;
      const viewportY = viewport.pageTop;

      const viewportWidth = win.innerWidth;
      const viewportHeight = win.innerHeight;

      const left = Math.max(rect.x, viewportX);
      const top = Math.max(rect.y, viewportY);

      const right = Math.min(rect.x + rect.width, viewportX + viewportWidth);
      const bottom = Math.min(rect.y + rect.height, viewportY + viewportHeight);

      const width = Math.max(right - left, 0);
      const height = Math.max(bottom - top, 0);

      rect = new DOMRect(left, top, width, height);
    } else if (full) {
      const docEl = win.document.documentElement;
      rect = new DOMRect(0, 0, docEl.scrollWidth, docEl.scrollHeight);
    } else {
      // viewport
      rect = new DOMRect(
        win.pageXOffset,
        win.pageYOffset,
        win.innerWidth,
        win.innerHeight
      );
    }

    return rect;
  }

  /**
   * Return the shadowRoot attached to an element
   */
  async getShadowRoot(options = {}) {
    const { elem } = options;

    return lazy.dom.getShadowRoot(elem);
  }

  /**
   * Determine the element displayedness of the given web element.
   */
  async isElementDisplayed(options = {}) {
    const { capabilities, elem } = options;

    return lazy.interaction.isElementDisplayed(
      elem,
      capabilities["moz:accessibilityChecks"]
    );
  }

  /**
   * Check if element is enabled.
   */
  async isElementEnabled(options = {}) {
    const { capabilities, elem } = options;

    return lazy.interaction.isElementEnabled(
      elem,
      capabilities["moz:accessibilityChecks"]
    );
  }

  /**
   * Determine whether the referenced element is selected or not.
   */
  async isElementSelected(options = {}) {
    const { capabilities, elem } = options;

    return lazy.interaction.isElementSelected(
      elem,
      capabilities["moz:accessibilityChecks"]
    );
  }

  /*
   * Send key presses to element after focusing on it.
   */
  async sendKeysToElement(options = {}) {
    const { capabilities, elem, text } = options;

    const opts = {
      strictFileInteractability: capabilities.strictFileInteractability,
      accessibilityChecks: capabilities["moz:accessibilityChecks"],
      webdriverClick: capabilities["moz:webdriverClick"],
    };

    return lazy.interaction.sendKeysToElement(elem, text, opts);
  }

  /**
   * Switch to the specified frame.
   *
   * @param {object=} options
   * @param {(number|Element)=} options.id
   *     If it's a number treat it as the index for all the existing frames.
   *     If it's an Element switch to this specific frame.
   *     If not specified or `null` switch to the top-level browsing context.
   */
  async switchToFrame(options = {}) {
    const { id } = options;

    const childContexts = this.browsingContext.children;
    let browsingContext;

    if (id == null) {
      browsingContext = this.browsingContext.top;
    } else if (typeof id == "number") {
      if (id < 0 || id >= childContexts.length) {
        throw new lazy.error.NoSuchFrameError(
          `Unable to locate frame with index: ${id}`
        );
      }
      browsingContext = childContexts[id];
    } else {
      const context = childContexts.find(childContext => {
        return childContext.embedderElement === id;
      });
      if (!context) {
        throw new lazy.error.NoSuchFrameError(
          `Unable to locate frame for element: ${id}`
        );
      }
      browsingContext = context;
    }

    // For in-process iframes the window global is lazy-loaded for optimization
    // reasons. As such force the currentWindowGlobal to be created so we always
    // have a window (bug 1691348).
    browsingContext.window;

    return { browsingContextId: browsingContext.id };
  }

  /**
   * Switch to the parent frame.
   */
  async switchToParentFrame() {
    const browsingContext = this.browsingContext.parent || this.browsingContext;

    return { browsingContextId: browsingContext.id };
  }
}
