/* 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 { WindowGlobalBiDiModule } from "chrome://remote/content/webdriver-bidi/modules/WindowGlobalBiDiModule.sys.mjs";

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  accessibility:
    "chrome://remote/content/shared/webdriver/Accessibility.sys.mjs",
  AnimationFramePromise: "chrome://remote/content/shared/Sync.sys.mjs",
  assert: "chrome://remote/content/shared/webdriver/Assert.sys.mjs",
  ClipRectangleType:
    "chrome://remote/content/webdriver-bidi/modules/root/browsingContext.sys.mjs",
  error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs",
  EventPromise: "chrome://remote/content/shared/Sync.sys.mjs",
  LoadListener: "chrome://remote/content/shared/listeners/LoadListener.sys.mjs",
  LocatorType:
    "chrome://remote/content/webdriver-bidi/modules/root/browsingContext.sys.mjs",
  OriginType:
    "chrome://remote/content/webdriver-bidi/modules/root/browsingContext.sys.mjs",
  OwnershipModel: "chrome://remote/content/webdriver-bidi/RemoteValue.sys.mjs",
  pprint: "chrome://remote/content/shared/Format.sys.mjs",
});

const DOCUMENT_FRAGMENT_NODE = 11;
const DOCUMENT_NODE = 9;
const ELEMENT_NODE = 1;

const ORDERED_NODE_SNAPSHOT_TYPE = 7;

class BrowsingContextModule extends WindowGlobalBiDiModule {
  #contextCreatedHandled;
  #loadListener;
  #subscribedEvents;

  constructor(messageHandler) {
    super(messageHandler);

    // Setup the LoadListener as early as possible.
    this.#loadListener = new lazy.LoadListener(this.messageHandler.window);
    this.#loadListener.on("DOMContentLoaded", this.#onDOMContentLoaded);
    this.#loadListener.on("load", this.#onLoad);

    // Set of event names which have active subscriptions.
    this.#subscribedEvents = new Set();
    this.#contextCreatedHandled = false;
  }

  destroy() {
    this.#loadListener.destroy();
    this.#subscribedEvents = null;
  }

  /**
   * Collect nodes using accessibility attributes.
   *
   * @see https://w3c.github.io/webdriver-bidi/#collect-nodes-using-accessibility-attributes
   */
  async #collectNodesUsingAccessibilityAttributes(
    contextNodes,
    selector,
    maxReturnedNodeCount,
    returnedNodes
  ) {
    if (returnedNodes === null) {
      returnedNodes = [];
    }

    for (const contextNode of contextNodes) {
      let match = true;

      if (contextNode.nodeType === ELEMENT_NODE) {
        if ("role" in selector) {
          const role = await lazy.accessibility.getComputedRole(contextNode);

          if (selector.role !== role) {
            match = false;
          }
        }

        if ("name" in selector) {
          const name = await lazy.accessibility.getAccessibleName(contextNode);
          if (selector.name !== name) {
            match = false;
          }
        }
      } else {
        match = false;
      }

      if (match) {
        if (
          maxReturnedNodeCount !== null &&
          returnedNodes.length === maxReturnedNodeCount
        ) {
          break;
        }
        returnedNodes.push(contextNode);
      }

      const childNodes = [...contextNode.children];

      await this.#collectNodesUsingAccessibilityAttributes(
        childNodes,
        selector,
        maxReturnedNodeCount,
        returnedNodes
      );
    }

    return returnedNodes;
  }

  #getNavigationInfo(data) {
    // Note: the navigation id is collected in the parent-process and will be
    // added via event interception by the windowglobal-in-root module.
    return {
      context: this.messageHandler.context,
      timestamp: Date.now(),
      url: data.target.URL,
    };
  }

  #getOriginRectangle(origin) {
    const win = this.messageHandler.window;

    if (origin === lazy.OriginType.viewport) {
      const viewport = win.visualViewport;
      // Until it's clarified in the scope of the issue:
      // https://github.com/w3c/webdriver-bidi/issues/592
      // if we should take into account scrollbar dimensions, when calculating
      // the viewport size, we match the behavior of WebDriver Classic,
      // meaning we include scrollbar dimensions.
      return new DOMRect(
        viewport.pageLeft,
        viewport.pageTop,
        win.innerWidth,
        win.innerHeight
      );
    }

    const documentElement = win.document.documentElement;
    return new DOMRect(
      0,
      0,
      documentElement.scrollWidth,
      documentElement.scrollHeight
    );
  }

  /**
   * Locate the container element of a provided context id.
   *
   * @see https://w3c.github.io/webdriver-bidi/#locate-the-container-element
   */

  #locateContainer(contextId) {
    const returnedNodes = [];
    const context = BrowsingContext.get(contextId);
    const container = context.embedderElement;
    if (container) {
      returnedNodes.push(container);
    }

    return returnedNodes;
  }

  /**
   * Locate nodes using accessibility attributes.
   *
   * @see https://w3c.github.io/webdriver-bidi/#locate-nodes-using-accessibility-attributes
   */
  async #locateNodesUsingAccessibilityAttributes(
    contextNodes,
    selector,
    maxReturnedNodeCount
  ) {
    if (!("role" in selector) && !("name" in selector)) {
      throw new lazy.error.InvalidSelectorError(
        "Locating nodes by accessibility attributes requires `role` or `name` arguments"
      );
    }

    return this.#collectNodesUsingAccessibilityAttributes(
      contextNodes,
      selector,
      maxReturnedNodeCount,
      null
    );
  }

  /**
   * Locate nodes using css selector.
   *
   * @see https://w3c.github.io/webdriver-bidi/#locate-nodes-using-css
   */
  #locateNodesUsingCss(contextNodes, selector, maxReturnedNodeCount) {
    const returnedNodes = [];

    for (const contextNode of contextNodes) {
      let elements;
      try {
        elements = contextNode.querySelectorAll(selector);
      } catch (e) {
        throw new lazy.error.InvalidSelectorError(
          `${e.message}: "${selector}"`
        );
      }

      if (maxReturnedNodeCount === null) {
        returnedNodes.push(...elements);
      } else {
        for (const element of elements) {
          returnedNodes.push(element);

          if (returnedNodes.length === maxReturnedNodeCount) {
            return returnedNodes;
          }
        }
      }
    }

    return returnedNodes;
  }

  /**
   * Locate nodes using XPath.
   *
   * @see https://w3c.github.io/webdriver-bidi/#locate-nodes-using-xpath
   */
  #locateNodesUsingXPath(contextNodes, selector, maxReturnedNodeCount) {
    const returnedNodes = [];

    for (const contextNode of contextNodes) {
      let evaluationResult;
      try {
        evaluationResult = this.messageHandler.window.document.evaluate(
          selector,
          contextNode,
          null,
          ORDERED_NODE_SNAPSHOT_TYPE,
          null
        );
      } catch (e) {
        const errorMessage = `${e.message}: "${selector}"`;
        if (DOMException.isInstance(e) && e.name === "SyntaxError") {
          throw new lazy.error.InvalidSelectorError(errorMessage);
        }

        throw new lazy.error.UnknownError(errorMessage);
      }

      for (let index = 0; index < evaluationResult.snapshotLength; index++) {
        const node = evaluationResult.snapshotItem(index);
        returnedNodes.push(node);

        if (
          maxReturnedNodeCount !== null &&
          returnedNodes.length === maxReturnedNodeCount
        ) {
          return returnedNodes;
        }
      }
    }

    return returnedNodes;
  }

  /**
   * Normalize rectangle. This ensures that the resulting rect has
   * positive width and height dimensions.
   *
   * @see https://w3c.github.io/webdriver-bidi/#normalise-rect
   *
   * @param {DOMRect} rect
   *     An object which describes the size and position of a rectangle.
   *
   * @returns {DOMRect} Normalized rectangle.
   */
  #normalizeRect(rect) {
    let { x, y, width, height } = rect;

    if (width < 0) {
      x += width;
      width = -width;
    }

    if (height < 0) {
      y += height;
      height = -height;
    }

    return new DOMRect(x, y, width, height);
  }

  #onDOMContentLoaded = (eventName, data) => {
    if (this.#subscribedEvents.has("browsingContext._documentInteractive")) {
      this.messageHandler.emitEvent("browsingContext._documentInteractive", {
        baseURL: data.target.baseURI,
        contextId: this.messageHandler.contextId,
        documentURL: data.target.URL,
        innerWindowId: this.messageHandler.innerWindowId,
        readyState: data.target.readyState,
      });
    }

    if (
      this.#subscribedEvents.has("browsingContext.domContentLoaded") &&
      this.#shouldSendLoadEvents(data.target)
    ) {
      this.emitEvent(
        "browsingContext.domContentLoaded",
        this.#getNavigationInfo(data)
      );
    }
  };

  #onLoad = (eventName, data) => {
    if (
      this.#subscribedEvents.has("browsingContext.load") &&
      this.#shouldSendLoadEvents(data.target)
    ) {
      this.emitEvent("browsingContext.load", this.#getNavigationInfo(data));
    }
  };

  /**
   * Create a new rectangle which will be an intersection of
   * rectangles specified as arguments.
   *
   * @see https://w3c.github.io/webdriver-bidi/#rectangle-intersection
   *
   * @param {DOMRect} rect1
   *     An object which describes the size and position of a rectangle.
   * @param {DOMRect} rect2
   *     An object which describes the size and position of a rectangle.
   *
   * @returns {DOMRect} Rectangle, representing an intersection of <var>rect1</var> and <var>rect2</var>.
   */
  #rectangleIntersection(rect1, rect2) {
    rect1 = this.#normalizeRect(rect1);
    rect2 = this.#normalizeRect(rect2);

    const x_min = Math.max(rect1.x, rect2.x);
    const x_max = Math.min(rect1.x + rect1.width, rect2.x + rect2.width);

    const y_min = Math.max(rect1.y, rect2.y);
    const y_max = Math.min(rect1.y + rect1.height, rect2.y + rect2.height);

    const width = Math.max(x_max - x_min, 0);
    const height = Math.max(y_max - y_min, 0);

    return new DOMRect(x_min, y_min, width, height);
  }

  #shouldSendLoadEvents(target) {
    // Do not emit the load events for the initial "about:blank" in top-level browsing contexts.
    return !target.isInitialDocument || this.messageHandler.context.parent;
  }

  #startListening() {
    if (this.#subscribedEvents.size == 0) {
      this.#loadListener.startListening();
    }
  }

  #stopListening() {
    if (this.#subscribedEvents.size == 0) {
      this.#loadListener.stopListening();
    }
  }

  #subscribeEvent(event) {
    switch (event) {
      case "browsingContext._documentInteractive":
        this.#startListening();
        this.#subscribedEvents.add("browsingContext._documentInteractive");
        break;
      case "browsingContext.domContentLoaded":
        this.#startListening();
        this.#subscribedEvents.add("browsingContext.domContentLoaded");
        break;
      case "browsingContext.load":
        this.#startListening();
        this.#subscribedEvents.add("browsingContext.load");
        break;
    }
  }

  #unsubscribeEvent(event) {
    switch (event) {
      case "browsingContext._documentInteractive":
        this.#subscribedEvents.delete("browsingContext._documentInteractive");
        break;
      case "browsingContext.domContentLoaded":
        this.#subscribedEvents.delete("browsingContext.domContentLoaded");
        break;
      case "browsingContext.load":
        this.#subscribedEvents.delete("browsingContext.load");
        break;
    }

    this.#stopListening();
  }

  /**
   * Internal commands
   */

  _applySessionData(params) {
    // TODO: Bug 1775231. Move this logic to a shared module or an abstract
    // class.
    const { category } = params;
    if (category === "event") {
      const filteredSessionData = params.sessionData.filter(item =>
        this.messageHandler.matchesContext(item.contextDescriptor)
      );
      for (const event of this.#subscribedEvents.values()) {
        const hasSessionItem = filteredSessionData.some(
          item => item.value === event
        );
        // If there are no session items for this context, we should unsubscribe from the event.
        if (!hasSessionItem) {
          this.#unsubscribeEvent(event);
        }
      }

      // Subscribe to all events, which have an item in SessionData.
      for (const { value } of filteredSessionData) {
        /**
         * We only want to emit backfill events when subscribing to the contextCreated
         * event. We also do not want to emit a backfill when creating the context
         * initially as this is already emitted elsewhere, so backfilling it would
         * cause a duplicate event to be emitted.
         */
        if (value === "browsingContext.contextCreated") {
          /**
           * We check for contextCreatedHandled so we do not replay any contextCreated events we
           * have seen before. This can happen when navigating within a browser session as
           * navigation will cause _applySessionData to be called with params.initial = false.
           */
          if (!params.initial && !this.#contextCreatedHandled) {
            this.emitEvent("browsingContext.contextCreated", {
              context: this.messageHandler.context,
            });

            // This is an internal event used by the script module
            // to ensure that "script.realmCreated" event is emitted
            // after "browsingContext.contextCreated".
            this.emitEvent("browsingContext._contextCreatedEmitted", {
              browsingContext: this.messageHandler.context,
            });
          }

          this.#contextCreatedHandled = true;
        }

        this.#subscribeEvent(value);
      }
    }
  }

  /**
   * Waits until the viewport has reached the new dimensions.
   *
   * @param {object} options
   * @param {number} options.height
   *     Expected height the viewport will resize to.
   * @param {number} options.width
   *     Expected width the viewport will resize to.
   *
   * @returns {Promise}
   *     Promise that resolves when the viewport has been resized.
   */
  async _awaitViewportDimensions(options) {
    const { height, width } = options;

    const win = this.messageHandler.window;
    let resized;

    // Updates for background tabs are throttled, and we also have to make
    // sure that the new browser dimensions have been received by the content
    // process. As such wait for the next animation frame.
    await lazy.AnimationFramePromise(win);

    const checkBrowserSize = () => {
      if (win.innerWidth === width && win.innerHeight === height) {
        resized();
      }
    };

    return new Promise(resolve => {
      resized = resolve;

      win.addEventListener("resize", checkBrowserSize);

      // Trigger a layout flush in case none happened yet.
      checkBrowserSize();
    }).finally(() => {
      win.removeEventListener("resize", checkBrowserSize);
    });
  }

  /**
   * Waits until the visibility state of the document has the expected value.
   *
   * @param {object} options
   * @param {number=} options.timeout
   *     Timeout in ms. Optional, if not provided, the command will only resolve
   *     when the expected state is met.
   * @param {number} options.value
   *     Expected value of the visibility state.
   *
   * @returns {Promise}
   *     Promise that resolves when the visibility state has the expected value,
   *     or the timeout has been reached.
   */
  async _awaitVisibilityState(options) {
    const { timeout = null, value } = options;
    const win = this.messageHandler.window;

    if (win.document.visibilityState === value) {
      // If the document visibilityState already has the expected value, resolve
      // immediately.
      return;
    }

    try {
      // Otherwise, wait for the next visibilitychange event.
      await new lazy.EventPromise(win, "visibilitychange", { timeout });
    } catch (e) {
      if (e instanceof lazy.error.TimeoutError) {
        // Swallow the exception thrown by the EventPromise if we simply
        // reached the timeout. Here the timeout is meant as an escape hatch,
        // but we should still resolve
        return;
      }
      throw e;
    }
  }

  _getBaseURL() {
    return this.messageHandler.window.document.baseURI;
  }

  _getScreenshotRect(params = {}) {
    const { clip, origin } = params;

    const originRect = this.#getOriginRectangle(origin);
    let clipRect = originRect;

    if (clip !== null) {
      switch (clip.type) {
        case lazy.ClipRectangleType.Box: {
          clipRect = new DOMRect(
            clip.x + originRect.x,
            clip.y + originRect.y,
            clip.width,
            clip.height
          );
          break;
        }

        case lazy.ClipRectangleType.Element: {
          const realm = this.messageHandler.getRealm();
          const element = this.deserialize(clip.element, realm);
          const viewportRect = this.#getOriginRectangle(
            lazy.OriginType.viewport
          );
          const elementRect = element.getBoundingClientRect();

          clipRect = new DOMRect(
            elementRect.x + viewportRect.x,
            elementRect.y + viewportRect.y,
            elementRect.width,
            elementRect.height
          );
          break;
        }
      }
    }

    return this.#rectangleIntersection(originRect, clipRect);
  }

  async _locateNodes(params = {}) {
    const { locator, maxNodeCount, serializationOptions, startNodes } = params;

    const realm = this.messageHandler.getRealm();

    const contextNodes = [];
    if (startNodes === null) {
      contextNodes.push(this.messageHandler.window.document);
    } else {
      for (const serializedStartNode of startNodes) {
        const startNode = this.deserialize(serializedStartNode, realm);
        lazy.assert.that(
          startNode =>
            Node.isInstance(startNode) &&
            [DOCUMENT_FRAGMENT_NODE, DOCUMENT_NODE, ELEMENT_NODE].includes(
              startNode.nodeType
            ),
          lazy.pprint`Expected an item of "startNodes" to be an Element, got ${startNode}`
        )(startNode);

        contextNodes.push(startNode);
      }
    }

    let returnedNodes;
    switch (locator.type) {
      case lazy.LocatorType.accessibility: {
        returnedNodes = await this.#locateNodesUsingAccessibilityAttributes(
          contextNodes,
          locator.value,
          maxNodeCount
        );
        break;
      }
      case lazy.LocatorType.context: {
        returnedNodes = this.#locateContainer(locator.value.context);
        break;
      }
      case lazy.LocatorType.css: {
        returnedNodes = this.#locateNodesUsingCss(
          contextNodes,
          locator.value,
          maxNodeCount
        );
        break;
      }
      case lazy.LocatorType.xpath: {
        returnedNodes = this.#locateNodesUsingXPath(
          contextNodes,
          locator.value,
          maxNodeCount
        );
        break;
      }
    }

    const serializedNodes = [];
    const seenNodeIds = new Map();
    for (const returnedNode of returnedNodes) {
      serializedNodes.push(
        this.serialize(
          returnedNode,
          serializationOptions,
          lazy.OwnershipModel.None,
          realm,
          { seenNodeIds }
        )
      );
    }

    return {
      serializedNodes,
      _extraData: { seenNodeIds },
    };
  }
}

export const browsingContext = BrowsingContextModule;
