/* 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 https://mozilla.org/MPL/2.0/. */

// @ts-check

/**
 * @see {extractTextFromDOM} for a high level overview of this file.
 */

/**
 * @import { GetTextOptions, DOMExtractionResult, ExtractionStrategy } from './PageExtractor.d.ts'
 */

import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";

const lazy = XPCOMUtils.declareLazy({
  SearchStaticData:
    "moz-src:///toolkit/components/search/SearchStaticData.sys.mjs",
});

const WHITESPACE_REGEX = /\s+/g;
const MARKDOWN_TEXT_ESCAPE_REGEX = /[\[\]()]/g;
const OPEN_PAREN_REGEX = /\(/g;
const CLOSE_PAREN_REGEX = /\)/g;

const DEFAULT_STRATEGY = Object.freeze({
  filterSelector: null,
  formatBlockAnchorsAsMarkdown: false,
  formatBlockAnchorSelector: null,
});

const GOOGLE_SEARCH_STRATEGY = Object.freeze({
  filterSelector: "cite",
  formatBlockAnchorsAsMarkdown: true,
  formatBlockAnchorSelector: "cite",
});

/**
 * The context for extracting text content from the DOM.
 */
class ExtractionContext {
  /**
   * Set of nodes that have already been processed, used to avoid duplicating text extraction.
   *
   * @type {Set<Node>}
   */
  #processedNodes = new Set();

  /**
   * Set of anchors that have already had their content formatted as a markdown link.
   * Used to prevent duplicate markdown links for multiple blocks inside the same anchor.
   *
   * @type {Set<HTMLAnchorElement>}
   */
  #linkedAnchors = new Set();

  /**
   * The text-extraction options, provided at initialization.
   *
   * @type {GetTextOptions}
   */
  #options;

  /**
   * The accumulated text content that has been extracted from the DOM.
   *
   * @type {string}
   */
  #textContent = "";

  /**
   * @type {Set<string>}
   */
  #links = new Set();

  /**
   * @type {Set<HTMLCanvasElement>}
   */
  #canvases = new Set();

  /**
   * @type {number}
   */
  #minCanvasSize;

  /**
   * @type {number}
   */
  #maxCanvasCount;

  /**
   * When extracting content just from the viewport, this value will be set.
   *
   * @type {{ top: number; left: number; right: number; bottom: number } | null}
   */
  #viewportRect = null;

  /**
   * @type {ExtractionStrategy}
   */
  #strategy = DEFAULT_STRATEGY;

  /**
   * Constructs a new extraction context with the provided options.
   *
   * @param {Document} document
   * @param {GetTextOptions} options
   */
  constructor(document, options) {
    this.#options = options;
    this.#minCanvasSize = options.minCanvasSize ?? 50;
    this.#maxCanvasCount = options.includeCanvasSnapshots
      ? (options.maxCanvasCount ?? 10)
      : 0;

    if (options.justViewport) {
      const { visualViewport } = document.defaultView;
      const { offsetTop, offsetLeft, width, height } = visualViewport;
      this.#viewportRect = {
        top: offsetTop,
        left: offsetLeft,
        right: offsetLeft + width,
        bottom: offsetTop + height,
      };
    }

    if (options.sourceUrl) {
      this.#strategy = getStrategyForUrl(URL.parse(options.sourceUrl));
    }
  }

  /**
   * Returns true if the element should be filtered based on current site strategy.
   *
   * @param {Node} node
   * @returns {boolean}
   */
  shouldFilterNode(node) {
    const filterSelector = this.#strategy.filterSelector;
    if (!filterSelector) {
      return false;
    }
    const element = asElement(node);
    if (!element) {
      return false;
    }
    return element.matches(filterSelector);
  }

  /**
   * Accumulated text content produced during traversal.
   *
   * @returns {string}
   */
  get textContent() {
    return this.#textContent;
  }

  /**
   * @returns {string[]}
   */
  get links() {
    return Array.from(this.#links);
  }

  /**
   * @returns {HTMLCanvasElement[]}
   */
  get canvases() {
    return Array.from(this.#canvases);
  }

  /**
   * @param {string} href
   */
  maybeAddLink(href) {
    this.#links.add(href);
  }

  /**
   * Add href from an anchor element if it has one.
   *
   * @param {HTMLAnchorElement} anchor
   */
  #addHrefFromAnchor(anchor) {
    if (anchor.hasAttribute("href")) {
      const href = anchor.href;
      if (href) {
        this.#links.add(href);
      }
    }
  }

  /**
   * Get an ancestor anchor element for block content.
   * Returns null for top-level elements or if no ancestor anchor exists.
   *
   * @param {Element} element
   * @returns {HTMLAnchorElement | null}
   */
  #getAncestorAnchor(element) {
    const { nodeName } = element;
    if (nodeName === "BODY" || nodeName === "HTML") {
      return null;
    }
    const ancestor = element.closest("a");
    if (ancestor?.hasAttribute("href")) {
      return /** @type {HTMLAnchorElement} */ (ancestor);
    }
    return null;
  }

  /**
   * @param {HTMLCanvasElement} canvas
   */
  #maybeAddCanvas(canvas) {
    const canvasSet = this.#canvases;

    if (canvasSet.has(canvas)) {
      return;
    }

    if (canvasSet.size >= this.#maxCanvasCount) {
      return;
    }

    const minSize = this.#minCanvasSize;
    if (canvas.width < minSize || canvas.height < minSize) {
      return;
    }

    if (isNodeHidden(canvas) || this.maybeOutOfViewport(canvas)) {
      return;
    }

    canvasSet.add(canvas);
  }

  /**
   * If this node is an anchor element, add its href to the links set.
   * Used for container nodes that will be subdivided, to capture anchors
   * that wrap block-level content.
   *
   * @param {Node} node
   */
  addLinkIfAnchor(node) {
    const element = asElement(node);
    if (element?.nodeName === "A") {
      const href = /** @type {HTMLAnchorElement} */ (element).href;
      if (href) {
        this.maybeAddLink(href);
      }
    }
  }

  /**
   * Extract all links from a node using querySelector.
   * Should only be called on leaf/accepted blocks, not on containers
   * that will be subdivided.
   *
   * @param {Node} node
   */
  extractLinksFromBlock(node) {
    const element = asElement(node);
    if (!element) {
      return;
    }

    if (element.nodeName === "A") {
      this.#addHrefFromAnchor(/** @type {HTMLAnchorElement} */ (element));
    } else {
      const ancestorAnchor = this.#getAncestorAnchor(element);
      if (ancestorAnchor) {
        this.#addHrefFromAnchor(ancestorAnchor);
      }
    }

    const anchors = element.getElementsByTagName("a");
    for (let i = 0, len = anchors.length; i < len; i++) {
      this.#addHrefFromAnchor(anchors[i]);
    }
  }

  /**
   * Extract all canvases from a node.
   *
   * @param {Node} node
   */
  extractCanvasesFromBlock(node) {
    const canvasSet = this.#canvases;
    const maxCount = this.#maxCanvasCount;

    if (canvasSet.size >= maxCount) {
      return;
    }

    const element = asElement(node);
    if (!element) {
      return;
    }

    if (element.tagName === "CANVAS") {
      this.#maybeAddCanvas(/** @type {HTMLCanvasElement} */ (element));
      return;
    }

    const canvases = element.getElementsByTagName("canvas");
    const len = canvases.length;

    if (len === 0) {
      return;
    }

    for (let i = 0; i < len; i++) {
      if (canvasSet.size >= maxCount) {
        break;
      }
      this.#maybeAddCanvas(canvases[i]);
    }
  }

  /**
   * Returns true if a condition has been met such that the text
   * extraction should stop early, otherwise false.
   *
   * @returns {boolean}
   */
  shouldStopExtraction() {
    const { sufficientLength } = this.#options;

    if (
      sufficientLength !== undefined &&
      this.#textContent.length >= sufficientLength
    ) {
      return true;
    }

    return false;
  }

  /**
   * Returns true if this node or its ancestor's text content has
   * already been extracted from the DOM.
   *
   * @param {Node} node
   */
  #isNodeProcessed(node) {
    if (this.#processedNodes.has(node)) {
      return true;
    }

    for (const ancestor of getAncestorsIterator(node)) {
      if (this.#processedNodes.has(ancestor)) {
        return true;
      }
    }
    return false;
  }

  /**
   * When capturing content only in the viewport, skip nodes that are outside of it.
   *
   * @param {Node} node
   */
  maybeOutOfViewport(node) {
    if (!this.#viewportRect) {
      // We don't have a viewport rect, so skip this check.
      return false;
    }
    const element = getHTMLElementForStyle(node);
    if (!element) {
      return false;
    }

    const rect = element.getBoundingClientRect();
    if (!rect) {
      return false;
    }

    return (
      rect.bottom <= this.#viewportRect.top ||
      rect.top >= this.#viewportRect.bottom ||
      rect.right <= this.#viewportRect.left ||
      rect.left >= this.#viewportRect.right
    );
  }

  /**
   * Append the node's text content to the accumulated text only if the node
   * itself as well as no ancestor of the node has already been processed.
   *
   * @param {Node} node
   */
  maybeAppendTextContent(node) {
    if (this.#isNodeProcessed(node)) {
      return;
    }

    this.#processedNodes.add(node);

    if (isNodeHidden(node)) {
      return;
    }

    if (this.maybeOutOfViewport(node)) {
      // This only can return true when we're capturing just the viewport nodes.
      return;
    }

    const element = asHTMLElement(node);
    const text = asTextNode(node);
    let innerText = "";

    if (element) {
      if (this.#hasInlineAnchors(element) || this.#strategy.filterSelector) {
        innerText = this.#extractTextWithMarkdownLinks(element);
      } else {
        innerText = element.innerText.trim();
      }

      // Wrap as markdown link if block is inside an ancestor anchor.
      // Only format once per anchor to avoid duplicate links
      if (this.#strategy.formatBlockAnchorsAsMarkdown && innerText) {
        const ancestorAnchor = this.#getAncestorAnchor(element);
        const selector = this.#strategy.formatBlockAnchorSelector;
        if (
          ancestorAnchor?.href &&
          !this.#linkedAnchors.has(ancestorAnchor) &&
          (!selector || ancestorAnchor.querySelector(selector))
        ) {
          innerText = escapeMarkdownLink(innerText, ancestorAnchor.href);
          this.#linkedAnchors.add(ancestorAnchor);
        }
      }
    } else if (text?.nodeValue) {
      innerText = text.nodeValue.trim();
    }

    if (innerText) {
      if (node.documentGlobal) {
        // Use whitespace behavior from the DOM.
        this.#textContent += "\n" + innerText;
      } else {
        // Manually collapse whitespace since the DOM is not attached to a window.
        // The behavior of innerText is different here, and whitespace does not
        // automatically get collapsed.
        this.#textContent = collapseWhitespace(innerText, this.#textContent);
      }
    }
  }

  /**
   * Check if a block contains any inline anchors that should be formatted as markdown.
   * Anchors that wrap block content are excluded since they will be handled by
   * the block splitting strategy.
   *
   * @param {HTMLElement} element
   * @returns {boolean}
   */
  #hasInlineAnchors(element) {
    if (element.nodeName === "A") {
      return !this.#wrapsBlockContent(element);
    }

    const anchors = element.querySelectorAll("a");
    for (const anchor of anchors) {
      if (!this.#wrapsBlockContent(anchor)) {
        return true;
      }
    }

    return false;
  }

  /**
   * Extract text from an element, formatting inline anchors as markdown.
   * Uses a TreeWalker to traverse the content in document order without
   * cloning or modifying the DOM.
   *
   * @param {HTMLElement} element
   * @returns {string}
   */
  #extractTextWithMarkdownLinks(element) {
    // Handle the simple case where the element itself is an inline anchor
    if (element.nodeName === "A" && !this.#wrapsBlockContent(element)) {
      return this.#formatAnchorAsMarkdown(element);
    }

    const parts = [];
    this.#walkAndExtract(element, parts);
    // Normalize whitespace for clean output
    return parts.join("").replace(WHITESPACE_REGEX, " ").trim();
  }

  /**
   * Recursively walk the DOM and extract text with various formatting options.
   * Handles both markdown link formatting and filtering.
   *
   * @param {Node} node
   * @param {string[]} parts
   */
  #walkAndExtract(node, parts) {
    if (node.nodeType === Node.TEXT_NODE) {
      const text = node.nodeValue ?? "";
      if (text) {
        parts.push(text);
      }
      return;
    }

    const element = asElement(node);
    if (!element) {
      return;
    }

    const filterSelector = this.#strategy.filterSelector;
    if (filterSelector && element.matches(filterSelector)) {
      return;
    }

    // If this is an anchor, check if it wraps block content
    if (element.nodeName === "A") {
      if (this.#wrapsBlockContent(element)) {
        // Anchor wraps block content - extract children normally without markdown
        for (const child of element.childNodes) {
          this.#walkAndExtract(child, parts);
        }
      } else {
        // Inline anchor - format as markdown
        parts.push(this.#formatAnchorAsMarkdown(element));
      }
      return;
    }

    // For other elements, recurse into children
    for (const child of element.childNodes) {
      this.#walkAndExtract(child, parts);
    }
  }

  /**
   * Format an anchor element as markdown [text](url).
   * Uses the resolved href property for the URL to get absolute URLs.
   *
   * @param {HTMLAnchorElement} anchor
   * @returns {string}
   */
  #formatAnchorAsMarkdown(anchor) {
    // Normalize whitespace in link text for clean markdown output
    // e.g., <a>Some \n  text</a> becomes [Some text](url)
    let linkText = (anchor.textContent ?? "")
      .replace(WHITESPACE_REGEX, " ")
      .trim();

    // For image-only anchors, use alt text if available
    if (!linkText) {
      const img = anchor.querySelector("img");
      if (img) {
        linkText = (img.alt ?? "").trim();
      }
    }

    // No text means we can't produce meaningful markdown
    if (!linkText) {
      return "";
    }

    // Use anchor.href which provides the resolved (absolute) URL.
    // Empty href resolves to the current document URL, which is valid.
    const href = anchor.href;
    if (!href) {
      return linkText;
    }

    return escapeMarkdownLink(linkText, href);
  }

  /**
   * Check if an anchor element wraps block-level content.
   * Such anchors should not be formatted as markdown since their
   * content will be extracted separately by the block splitting strategy.
   * Checks recursively to handle cases like <a><span><div>...</div></span></a>.
   *
   * @param {Element} element
   * @returns {boolean}
   */
  #wrapsBlockContent(element) {
    for (const child of element.childNodes) {
      const childElement = asElement(child);
      if (!childElement) {
        continue;
      }
      if (getIsBlockLike(childElement)) {
        return true;
      }
      // Recursively check inline children for nested block content
      if (this.#wrapsBlockContent(childElement)) {
        return true;
      }
    }
    return false;
  }
}

/**
 * Extracts visible text content from the DOM.
 * By default, this extracts content from the entire page.
 *
 * Callers may specify filters for the extracted text via
 * the supported options @see {GetTextOptions}.
 *
 * @param {Document} document
 * @param {HTMLElement} rootNode
 * @param {GetTextOptions} options
 *
 * @returns {DOMExtractionResult}
 *
 * In-depth documentation:
 *
 * Webpages are complicated documents. There are many different semantic structures
 * like <article>, aria controls or even specifications like schema.org. The DOMExtractor
 * can use these as hints, but ultimately the goal is to extract the user visible text
 * from a webpage in the same way it is presented to the user. Text in layout is done
 * through inline elements that go through reflow within a block. The intent of this
 * algorithm is to collect all of the blocks on the screen, and convert each block into
 * a paragraph of plain text that is representative of the information that is displayed
 * on the screen.
 *
 * For example:
 *
 *   <article>
 *     <div>
 *       This <span>is an example</span> of a block with inline elements.
 *     </div>
 *     <span style="display: block">
 *       The <div style="display: inline">computed style</div> is respected for extraction.
 *     </span>
 *     <div style="display: none">
 *       Only visible text will be extracted.
 *     </div>
 *   </article>
 *
 * If extraction is run on this document you will get the following lines:
 *
 *   ```
 *   This is an example of a block with inline elements.\n
 *   The computed style is respected for extraction.\n
 *   ```
 *
 * This text should be formatted in a way that a language model can infer the meaning
 * of the page, and work efficiently with the returned structure. A user reads and
 * understands the content of the page based on how it's displayed to them. Therefore
 * a language model should get plain text that as closely resembles that.
 *
 * The DOMExtractor supports different modes to limit the amount of content, or provide
 * only information that is in the viewport. Ultimately it should be able to take any
 * type of request from things like the get_page_content tool call, and fulfill that
 * request in an efficient way that returns content as much as possible as how a user
 * would actually experience it once rendered to the page.
 *
 * This strategy differs from more traditional scraping methods, as the browser has
 * access to the full styled page. We can measure the computed style of elements to
 * determine visibility and the actually computed block status (e.g. "display: block"
 * and "display: inline")
 */
export function extractTextFromDOM(document, rootNode, options) {
  const context = new ExtractionContext(document, options);

  subdivideAndExtractText(rootNode, context);

  return {
    text: context.textContent.trim(),
    links: context.links,
    canvases: context.canvases,
  };
}

/**
 * Tags excluded from text extraction.
 */
const CONTENT_EXCLUDED_TAGS = new Set([
  // TODO - We should add this and write some tests.
  "CODE",

  // The following are deprecated tags.
  "DIR",
  "APPLET",

  // The following are embedded elements, and are not supported (yet).
  "MATH",
  "EMBED",
  "OBJECT",
  "IFRAME",

  // This is an SVG tag that can contain arbitrary XML, ignore it.
  "METADATA",

  // These are elements that are treated as opaque by Firefox which causes their
  // innerHTML property to be just the raw text node behind it. Any text that is sent as
  // HTML must be valid, and there is no guarantee that the innerHTML is valid.
  "NOSCRIPT",
  "NOEMBED",
  "NOFRAMES",

  // Do not parse the HEAD tag.
  "HEAD",

  // These are not user-visible tags.
  "STYLE",
  "SCRIPT",
  "TEMPLATE",
]);

const CONTENT_EXCLUDED_NODE_SELECTOR = [...CONTENT_EXCLUDED_TAGS].join(",");

/**
 * Get the ShadowRoot from the chrome-only openOrClosedShadowRoot API.
 * This allows for extracting the content from WebComponents, which is not
 * normally feasible in non-privileged contexts.
 *
 * @param {Node} node
 *
 * @returns {ShadowRoot | null}
 */
function getShadowRoot(node) {
  return asElement(node)?.openOrClosedShadowRoot ?? null;
}

/**
 * Escape brackets and parentheses in link text, and parentheses in URL for valid markdown
 *
 * @param {string} text
 * @param {string} url
 * @returns {string}
 */
function escapeMarkdownLink(text, url) {
  const escapedText = text.replace(MARKDOWN_TEXT_ESCAPE_REGEX, "\\$&");
  const escapedHref = url
    .replace(OPEN_PAREN_REGEX, "%28")
    .replace(CLOSE_PAREN_REGEX, "%29");
  return `[${escapedText}](${escapedHref})`;
}

/**
 * Returns the extraction strategy for the given URL.
 *
 * @param {URL | null} url
 * @returns {ExtractionStrategy}
 */
function getStrategyForUrl(url) {
  if (!url) {
    return DEFAULT_STRATEGY;
  }

  const { hostname, pathname, searchParams } = url;

  // Google search result page strategy:
  // Filter out <cite> elements which contain URL breadcrumbs like
  // "https://www.example.com > path > to > page" that confuse LLMs.
  // Only format block anchors as markdown if they contain a cite element.
  const matchedGoogleDomains =
    lazy.SearchStaticData.getAlternateDomains(hostname);
  const isGoogleSearch =
    matchedGoogleDomains.length &&
    pathname === "/search" &&
    searchParams.has("q");

  if (isGoogleSearch) {
    return GOOGLE_SEARCH_STRATEGY;
  }

  return DEFAULT_STRATEGY;
}

/**
 * Determines if a node is ready for text extraction, or if it should be subdivided
 * further. Rejects nodes that the site strategy filters out, so the entire subtree
 * is skipped without needing to check ancestors. It doesn't check if the node has
 * already been processed. This is done at the block level.
 *
 * @param {Node} node
 * @param {ExtractionContext} context
 * @returns {number} - NodeFilter acceptance status.
 */
function determineBlockStatus(node, context) {
  if (!node) {
    return NodeFilter.FILTER_REJECT;
  }
  if (context.shouldFilterNode(node)) {
    return NodeFilter.FILTER_REJECT;
  }
  if (getShadowRoot(node)) {
    return NodeFilter.FILTER_ACCEPT;
  }

  const canvasElement = asElement(node);
  if (canvasElement?.tagName === "CANVAS") {
    return NodeFilter.FILTER_ACCEPT;
  }

  if (isExcludedNode(node)) {
    // This is an explicit.
    return NodeFilter.FILTER_REJECT;
  }

  if (
    containsExcludedNode(node, CONTENT_EXCLUDED_NODE_SELECTOR) &&
    !hasNonWhitespaceTextNodes(node)
  ) {
    // Skip this node, and dig deeper into its tree to cut off smaller pieces to extract.
    return NodeFilter.FILTER_SKIP;
  }

  if (nodeNeedsSubdividing(node)) {
    // Skip this node, and dig deeper into its tree to cut off smaller pieces
    // to extract. It is presumed to be a wrapper of block elements.
    return NodeFilter.FILTER_SKIP;
  }

  // This textContent call is fairly expensive.
  if (!node.textContent?.trim().length) {
    // Check if this is an anchor with an image.
    // Accept these anchors so their links are captured, even without alt text.
    const anchorElement = asElement(node);
    if (anchorElement?.nodeName === "A") {
      const img = anchorElement.querySelector("img");
      if (img) {
        return NodeFilter.FILTER_ACCEPT;
      }
    }

    // Do not use subtrees that are empty of text.
    return !node.hasChildNodes()
      ? NodeFilter.FILTER_REJECT
      : NodeFilter.FILTER_SKIP;
  }

  // This node can be treated as entire block and is ready for text extraction.
  return NodeFilter.FILTER_ACCEPT;
}
/**
 * Determine if this element is an inline element or a block element.
 *
 * @param {Node} node
 * @returns {boolean}
 */
function nodeNeedsSubdividing(node) {
  const element = asElement(node);
  if (!element) {
    // Only elements need to be further subdivided.
    return false;
  }

  for (let childNode of element.childNodes) {
    if (!childNode) {
      continue;
    }
    switch (childNode.nodeType) {
      case Node.TEXT_NODE: {
        // Keep checking for more inline or text nodes.
        continue;
      }
      case Node.ELEMENT_NODE: {
        if (getIsBlockLike(childNode)) {
          // This node is a block node, so it needs further subdividing.
          return true;
        } else if (nodeNeedsSubdividing(childNode)) {
          // This non-block-like node may contain other block-like nodes.
          return true;
        }

        // Keep checking for more inline or text nodes.
        continue;
      }
      default: {
        return true;
      }
    }
  }
  return false;
}

/**
 * Returns true if a node is hidden based on factors such as collapsed state and
 * computed style, otherwise false.
 *
 * @param {Node} node
 * @returns {boolean}
 */
function isNodeHidden(node) {
  if (!node.documentGlobal) {
    // This node is not actually connected to a live browser context, so we can't
    // determine if it's hidden or not. This can happen for a DOMParser document.
    return false;
  }

  const element = getHTMLElementForStyle(node);

  if (!element) {
    // If we cannot get an HTMLElement to check visibility, we should not
    // consider the node hidden. This can happen with cross-compartment
    // elements where HTMLElement.isInstance fails.
    return false;
  }

  // This is a cheap and easy check that will not compute style or force reflow.
  if (element.hidden) {
    // The element is explicitly hidden.
    return true;
  }

  // Handle open/closed <details> elements. This will also not compute style or force reflow.
  // https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/details
  if (
    // The element is within a closed <details>
    element.closest("details:not([open])") &&
    // The element is not part of the <summary> of the <details>, which is always visible, even when closed.
    !element.closest("summary")
  ) {
    // The element is within a closed <details> and is not part of the <summary>, therefore it is not visible.
    return true;
  }

  // This forces reflow, which has a performance cost, but this is also what JQuery uses for its :hidden and :visible.
  // https://github.com/jquery/jquery/blob/bd6b453b7effa78b292812dbe218491624994526/src/css/hiddenVisibleSelectors.js#L1-L10
  if (
    !(
      element.offsetWidth ||
      element.offsetHeight ||
      element.getClientRects().length
    )
  ) {
    return true;
  }

  // The element may still have a zero-sized bounding client rectangle.
  const boundingClientRect = element.getBoundingClientRect();
  if (
    boundingClientRect &&
    (boundingClientRect.width === 0 || boundingClientRect.height === 0)
  ) {
    return true;
  }

  const { documentGlobal } = element;
  if (!documentGlobal) {
    // We cannot compute the style without documentGlobal, so we will assume it is not visible.
    return true;
  }

  // This flushes the style, which is a performance cost.
  const style = documentGlobal.getComputedStyle(element);
  if (!style) {
    // We were unable to compute the style, so we will assume it is not visible.
    return true;
  }

  // This is an issue with the DOM library generation.
  const { display, visibility, opacity } = style;

  return (
    display === "none" ||
    visibility === "hidden" ||
    visibility === "collapse" ||
    opacity === "0"
  );
}

/**
 * @param {Node} node
 */
function isExcludedNode(node) {
  // Property access be expensive, so destructure required properties so they are
  // not accessed multiple times.
  const { nodeType } = node;

  if (nodeType === Node.TEXT_NODE) {
    // Text nodes are never excluded.
    return false;
  }
  const element = asElement(node);
  if (!element) {
    // Only elements and and text nodes should be considered.
    return true;
  }

  const { nodeName } = element;

  if (CONTENT_EXCLUDED_TAGS.has(nodeName.toUpperCase())) {
    // SVG tags can be lowercased, so ensure everything is uppercased.
    // This is an excluded tag.
    return true;
  }

  return false;
}

/**
 * Like `#isExcludedNode` but looks at the full subtree. Used to see whether
 * we can consider a subtree, or whether we should split it into smaller
 * branches first to try to exclude more of the content.
 *
 * @param {Node} node
 * @param {string} excludedNodeSelector
 *
 * @returns {boolean}
 */
function containsExcludedNode(node, excludedNodeSelector) {
  return Boolean(asElement(node)?.querySelector(excludedNodeSelector));
}

/**
 * Test whether any of the direct child text nodes of are non-whitespace text nodes.
 *
 * For example:
 *   - `<p>test</p>`: yes
 *   - `<p> </p>`: no
 *   - `<p><b>test</b></p>`: no
 *
 * @param {Node} node
 *
 * @returns {boolean}
 */
function hasNonWhitespaceTextNodes(node) {
  if (node.nodeType !== Node.ELEMENT_NODE) {
    // Only check element nodes.
    return false;
  }

  for (const child of node.childNodes) {
    const textNode = asTextNode(child);
    if (textNode) {
      if (!textNode.textContent?.trim()) {
        // This is just whitespace.
        continue;
      }
      // A text node with content was found.
      return true;
    }
  }

  // No text nodes were found.
  return false;
}

/**
 * Start walking down through a node's subtree and decide which nodes to extract content
 * from. This first node is the root of the page.
 *
 * The nodes go through a process of subdivision until an appropriate sized chunk
 * of inline text can be found.
 *
 * @param {Node} node
 * @param {ExtractionContext} context
 */
function subdivideAndExtractText(node, context) {
  if (context.shouldStopExtraction()) {
    return;
  }

  switch (determineBlockStatus(node, context)) {
    case NodeFilter.FILTER_REJECT: {
      // This node is rejected as it shouldn't be used for text extraction.
      return;
    }

    // Either a shadow host or a block element
    case NodeFilter.FILTER_ACCEPT: {
      const shadowRoot = getShadowRoot(node);
      if (shadowRoot) {
        processSubdivide(shadowRoot, context);
      } else {
        context.extractLinksFromBlock(node);
        context.extractCanvasesFromBlock(node);
        context.maybeAppendTextContent(node);
      }
      break;
    }

    case NodeFilter.FILTER_SKIP: {
      // This node may have text to extract, but it needs to be subdivided into smaller
      // pieces. Create a TreeWalker to walk the subtree, and find the subtrees/nodes
      // that contain enough inline elements to extract.
      // Only check if this node itself is an anchor (for anchors wrapping block content).
      // Don't scan descendants here - they'll be processed when child blocks are accepted.
      context.addLinkIfAnchor(node);
      processSubdivide(node, context);
      break;
    }
  }
}

/**
 * Add qualified nodes to have their text content extracted by recursively walking
 * through the DOM tree of nodes, including elements in the Shadow DOM.
 *
 * @param {Node} node
 * @param {ExtractionContext} context
 */
function processSubdivide(node, context) {
  if (context.shouldStopExtraction()) {
    return;
  }

  const { ownerDocument } = node;
  if (!ownerDocument) {
    return;
  }

  // This iterator will contain each node that has been subdivided enough to have its
  // text extracted.
  const nodeIterator = ownerDocument.createTreeWalker(
    node,
    NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT,
    n => determineBlockStatus(n, context)
  );

  let currentNode;
  while ((currentNode = nodeIterator.nextNode())) {
    const shadowRoot = getShadowRoot(currentNode);
    if (shadowRoot) {
      processSubdivide(shadowRoot, context);
    } else {
      context.extractLinksFromBlock(currentNode);
      context.extractCanvasesFromBlock(currentNode);
      context.maybeAppendTextContent(currentNode);
    }
    if (context.shouldStopExtraction()) {
      return;
    }
  }
}

/**
 * Returns an iterator of a node's ancestors.
 *
 * @param {Node} node
 *
 * @yields {Node}
 */
function* getAncestorsIterator(node) {
  const document = node.ownerDocument;
  if (!document) {
    return;
  }
  for (
    let parent = node.parentNode;
    parent && parent !== document.documentElement;
    parent = parent.parentNode
  ) {
    yield parent;
  }
}

/**
 * This list is not really exhaustive, as it's just covering common block elements that
 * can be used in reader mode.
 */
// prettier-ignore
const blockLikeElements = new Set([
  "ARTICLE", "ASIDE", "BLOCKQUOTE", "BODY", "CAPTION", "COL", "COLGROUP", "DD", "DETAILS",
  "DIALOG", "DIV", "DL", "DT", "FIELDSET", "FIGCAPTION", "FIGURE", "FOOTER", "FORM",
  "H1", "H2", "H3", "H4", "H5", "H6", "HEADER", "HGROUP", "HR", "HTML", "LEGEND", "LI",
  "MAIN", "NAV", "OL", "P", "PRE", "SECTION", "TABLE", "TBODY", "TD", "TFOOT", "TH",
  "THEAD", "TR", "UL",
]);

/**
 * Reads the elements computed style and determines if the element is a block-like
 * element or not. Every element that lays out like a block should be used as a unit
 * for text extraction.
 *
 * @param {Node} node
 * @returns {boolean}
 */
function getIsBlockLike(node) {
  const element = asElement(node);
  if (!element) {
    return false;
  }

  const { documentGlobal } = element;
  if (!documentGlobal) {
    // This root node is detached from a window, and so there is no computed style.
    // Just use the assumed style.
    return blockLikeElements.has(element.tagName);
  }

  if (element.namespaceURI === "http://www.w3.org/2000/svg") {
    // SVG elements will report as inline, but there is no block layout in SVG.
    // Treat every SVG element as being block so that every node will be subdivided.
    return true;
  }

  /** @type {Record<string, string>} */
  // @ts-expect-error - This is a workaround for the CSSStyleDeclaration not being indexable.
  const style = documentGlobal.getComputedStyle(element) ?? { display: null };

  return style.display !== "inline" && style.display !== "none";
}

/**
 * Use TypeScript to determine if the Node is an Element.
 *
 * @param {Node | null | undefined} node
 * @returns {Element | null}
 */
function asElement(node) {
  if (node?.nodeType === Node.ELEMENT_NODE) {
    return /** @type {HTMLElement} */ (node);
  }
  return null;
}

/**
 * Use TypeScript to determine if the Node is an Element.
 *
 * @param {Node | null} node
 *
 * @returns {Text | null}
 */
function asTextNode(node) {
  if (node?.nodeType === Node.TEXT_NODE) {
    return /** @type {Text} */ (node);
  }
  return null;
}

/**
 * Use TypeScript to determine if the Node is an HTMLElement.
 *
 * @param {Node | null} node
 *
 * @returns {HTMLElement | null}
 */
function asHTMLElement(node) {
  if (HTMLElement.isInstance(node)) {
    return node;
  }
  return null;
}

/**
 * This function returns the correct element to determine the
 * style of node.
 *
 * @param {Node} node
 *
 * @returns {HTMLElement | null}
 */
function getHTMLElementForStyle(node) {
  const element = asHTMLElement(node);
  if (element) {
    return element;
  }

  if (node.parentElement) {
    return asHTMLElement(node.parentElement);
  }

  // For cases like text node where its parent is ShadowRoot,
  // we'd like to use flattenedTreeParentNode
  if (node.flattenedTreeParentNode) {
    return asHTMLElement(node.flattenedTreeParentNode);
  }

  // If the text node is not connected or doesn't have a frame.
  return null;
}

/**
 * Ensure whitespace isn't repeated in the text. This algorithm maintains at most 2
 * newlines in some whitespace, or 1 whitespace character. Only "\n" and " " are retained.
 * This is similar to the whitespace collapsing behavior of rendered HTML. Note that this
 * algorithm ignores Unicode whitespace characters, which are a larger set of potential
 * characters.
 *
 * https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Text/Whitespace
 *
 * So:
 *   "\t\n \t"       => "\n"
 *   "\t\n \t\n\n\n" => "\n\n"
 *   "example     text" => "example text"
 *   "\n\r"      => ""
 *
 * @param {string} currentText
 * @param {string} [previousText]
 * @returns {string}
 */
export function collapseWhitespace(currentText, previousText = "") {
  // Find the lastWhitespaceIndex in the previousText.
  let lastWhitespaceIndex;
  for (
    lastWhitespaceIndex = previousText.length;
    lastWhitespaceIndex > 0;
    lastWhitespaceIndex--
  ) {
    const ch = previousText[lastWhitespaceIndex - 1];
    if (ch != " " && ch != "\n" && ch != "\t" && ch != "\r") {
      break;
    }
  }

  // Collect the trailling whitespace from the previousText.
  let trailingWhitespace = previousText.slice(
    lastWhitespaceIndex,
    previousText.length
  );

  // Move the trailing whitespace from the previousText to the currentText so that
  // the whitespace collapses correctly.
  const prefixText = previousText.slice(0, lastWhitespaceIndex);
  const postfixText = trailingWhitespace + currentText;

  let collapsedText = "";
  let prevWasWhitespace = !!prefixText;
  let newLinesCount = prefixText ? 1 : 0;

  for (let i = 0; i < postfixText.length; i++) {
    const ch = postfixText[i];

    if (
      // Is this a whitespace character that is used in HTML whitespace collapsing?
      ch === " " ||
      ch === "\n" ||
      ch === "\t" ||
      ch === "\r"
    ) {
      // Remember that there was whitespace and count the newlines.
      if (ch === "\n") {
        newLinesCount++;
      }
      prevWasWhitespace = true;
    } else {
      // There is a character that needs to be added. Also add any whitespace that
      // was encountered.

      if (prevWasWhitespace) {
        // Add the collapsed version of the whitespace.
        if (newLinesCount == 0) {
          collapsedText += " ";
        } else if (newLinesCount == 1) {
          collapsedText += "\n";
        } else {
          collapsedText += "\n\n";
        }
        // Reset the whitespace tracking varaibles.
        newLinesCount = 0;
        prevWasWhitespace = false;
      }

      // Add the next character.
      collapsedText += ch;
    }
  }

  if (prevWasWhitespace) {
    // Add the collapsed version of the whitespace.
    if (newLinesCount == 0) {
      collapsedText += " ";
    } else if (newLinesCount == 1) {
      collapsedText += "\n";
    } else {
      collapsedText += "\n\n";
    }
  }

  return prefixText + collapsedText;
}
