'use strict';

var redent = require('redent');
var cssTools = require('@adobe/css-tools');
var domAccessibilityApi = require('dom-accessibility-api');
var ariaQuery = require('aria-query');
var pico = require('picocolors');
var escape = require('css.escape');

class GenericTypeError extends Error {
  constructor(expectedString, received, matcherFn, context) {
    super();

    /* istanbul ignore next */
    if (Error.captureStackTrace) {
      Error.captureStackTrace(this, matcherFn);
    }
    let withType = '';
    try {
      withType = context.utils.printWithType(
        'Received',
        received,
        context.utils.printReceived,
      );
    } catch (e) {
      // Can throw for Document:
      // https://github.com/jsdom/jsdom/issues/2304
    }
    this.message = [
      context.utils.matcherHint(
        `${context.isNot ? '.not' : ''}.${matcherFn.name}`,
        'received',
        '',
      ),
      '',
      // eslint-disable-next-line new-cap
      `${context.utils.RECEIVED_COLOR(
        'received',
      )} value must ${expectedString}.`,
      withType,
    ].join('\n');
  }
}

class HtmlElementTypeError extends GenericTypeError {
  constructor(...args) {
    super('be an HTMLElement or an SVGElement', ...args);
  }
}

class NodeTypeError extends GenericTypeError {
  constructor(...args) {
    super('be a Node', ...args);
  }
}

function checkHasWindow(htmlElement, ErrorClass, ...args) {
  if (
    !htmlElement ||
    !htmlElement.ownerDocument ||
    !htmlElement.ownerDocument.defaultView
  ) {
    throw new ErrorClass(htmlElement, ...args)
  }
}

function checkNode(node, ...args) {
  checkHasWindow(node, NodeTypeError, ...args);
  const window = node.ownerDocument.defaultView;

  if (!(node instanceof window.Node)) {
    throw new NodeTypeError(node, ...args)
  }
}

function checkHtmlElement(htmlElement, ...args) {
  checkHasWindow(htmlElement, HtmlElementTypeError, ...args);
  const window = htmlElement.ownerDocument.defaultView;

  if (
    !(htmlElement instanceof window.HTMLElement) &&
    !(htmlElement instanceof window.SVGElement)
  ) {
    throw new HtmlElementTypeError(htmlElement, ...args)
  }
}

class InvalidCSSError extends Error {
  constructor(received, matcherFn, context) {
    super();

    /* istanbul ignore next */
    if (Error.captureStackTrace) {
      Error.captureStackTrace(this, matcherFn);
    }
    this.message = [
      received.message,
      '',
      // eslint-disable-next-line new-cap
      context.utils.RECEIVED_COLOR(`Failing css:`),
      // eslint-disable-next-line new-cap
      context.utils.RECEIVED_COLOR(`${received.css}`),
    ].join('\n');
  }
}

function parseCSS(css, ...args) {
  const ast = cssTools.parse(`selector { ${css} }`, {silent: true}).stylesheet;

  if (ast.parsingErrors && ast.parsingErrors.length > 0) {
    const {reason, line} = ast.parsingErrors[0];

    throw new InvalidCSSError(
      {
        css,
        message: `Syntax error parsing expected css: ${reason} on line: ${line}`,
      },
      ...args,
    )
  }

  const parsedRules = ast.rules[0].declarations
    .filter(d => d.type === 'declaration')
    .reduce(
      (obj, {property, value}) => Object.assign(obj, {[property]: value}),
      {},
    );
  return parsedRules
}

function display(context, value) {
  return typeof value === 'string' ? value : context.utils.stringify(value)
}

function getMessage(
  context,
  matcher,
  expectedLabel,
  expectedValue,
  receivedLabel,
  receivedValue,
) {
  return [
    `${matcher}\n`,
    // eslint-disable-next-line new-cap
    `${expectedLabel}:\n${context.utils.EXPECTED_COLOR(
      redent(display(context, expectedValue), 2),
    )}`,
    // eslint-disable-next-line new-cap
    `${receivedLabel}:\n${context.utils.RECEIVED_COLOR(
      redent(display(context, receivedValue), 2),
    )}`,
  ].join('\n')
}

function matches(textToMatch, matcher) {
  if (matcher instanceof RegExp) {
    return matcher.test(textToMatch)
  } else {
    return textToMatch.includes(String(matcher))
  }
}

function deprecate(name, replacementText) {
  // Notify user that they are using deprecated functionality.
  // eslint-disable-next-line no-console
  console.warn(
    `Warning: ${name} has been deprecated and will be removed in future updates.`,
    replacementText,
  );
}

function normalize(text) {
  return text.replace(/\s+/g, ' ').trim()
}

function getTag(element) {
  return element.tagName && element.tagName.toLowerCase()
}

function getSelectValue({multiple, options}) {
  const selectedOptions = [...options].filter(option => option.selected);

  if (multiple) {
    return [...selectedOptions].map(opt => opt.value)
  }
  /* istanbul ignore if */
  if (selectedOptions.length === 0) {
    return undefined // Couldn't make this happen, but just in case
  }
  return selectedOptions[0].value
}

function getInputValue(inputElement) {
  switch (inputElement.type) {
    case 'number':
      return inputElement.value === '' ? null : Number(inputElement.value)
    case 'checkbox':
      return inputElement.checked
    default:
      return inputElement.value
  }
}

const rolesSupportingValues = ['meter', 'progressbar', 'slider', 'spinbutton'];
function getAccessibleValue(element) {
  if (!rolesSupportingValues.includes(element.getAttribute('role'))) {
    return undefined
  }
  return Number(element.getAttribute('aria-valuenow'))
}

function getSingleElementValue(element) {
  /* istanbul ignore if */
  if (!element) {
    return undefined
  }

  switch (element.tagName.toLowerCase()) {
    case 'input':
      return getInputValue(element)
    case 'select':
      return getSelectValue(element)
    default: {
      return element.value ?? getAccessibleValue(element)
    }
  }
}

function toSentence(
  array,
  {wordConnector = ', ', lastWordConnector = ' and '} = {},
) {
  return [array.slice(0, -1).join(wordConnector), array[array.length - 1]].join(
    array.length > 1 ? lastWordConnector : '',
  )
}

function compareAsSet(val1, val2) {
  if (Array.isArray(val1) && Array.isArray(val2)) {
    return [...new Set(val1)].every(v => new Set(val2).has(v))
  }
  return val1 === val2
}

function toBeInTheDOM(element, container) {
  deprecate(
    'toBeInTheDOM',
    'Please use toBeInTheDocument for searching the entire document and toContainElement for searching a specific container.',
  );

  if (element) {
    checkHtmlElement(element, toBeInTheDOM, this);
  }

  if (container) {
    checkHtmlElement(container, toBeInTheDOM, this);
  }

  return {
    pass: container ? container.contains(element) : !!element,
    message: () => {
      return [
        this.utils.matcherHint(
          `${this.isNot ? '.not' : ''}.toBeInTheDOM`,
          'element',
          '',
        ),
        '',
        'Received:',
        `  ${this.utils.printReceived(
          element ? element.cloneNode(false) : element,
        )}`,
      ].join('\n')
    },
  }
}

function toBeInTheDocument(element) {
  if (element !== null || !this.isNot) {
    checkHtmlElement(element, toBeInTheDocument, this);
  }

  const pass =
    element === null
      ? false
      : element.ownerDocument === element.getRootNode({composed: true});

  const errorFound = () => {
    return `expected document not to contain element, found ${this.utils.stringify(
      element.cloneNode(true),
    )} instead`
  };
  const errorNotFound = () => {
    return `element could not be found in the document`
  };

  return {
    pass,
    message: () => {
      return [
        this.utils.matcherHint(
          `${this.isNot ? '.not' : ''}.toBeInTheDocument`,
          'element',
          '',
        ),
        '',
        // eslint-disable-next-line new-cap
        this.utils.RECEIVED_COLOR(this.isNot ? errorFound() : errorNotFound()),
      ].join('\n')
    },
  }
}

function toBeEmpty(element) {
  deprecate(
    'toBeEmpty',
    'Please use instead toBeEmptyDOMElement for finding empty nodes in the DOM.',
  );
  checkHtmlElement(element, toBeEmpty, this);

  return {
    pass: element.innerHTML === '',
    message: () => {
      return [
        this.utils.matcherHint(
          `${this.isNot ? '.not' : ''}.toBeEmpty`,
          'element',
          '',
        ),
        '',
        'Received:',
        `  ${this.utils.printReceived(element.innerHTML)}`,
      ].join('\n')
    },
  }
}

function toBeEmptyDOMElement(element) {
  checkHtmlElement(element, toBeEmptyDOMElement, this);

  return {
    pass: isEmptyElement(element),
    message: () => {
      return [
        this.utils.matcherHint(
          `${this.isNot ? '.not' : ''}.toBeEmptyDOMElement`,
          'element',
          '',
        ),
        '',
        'Received:',
        `  ${this.utils.printReceived(element.innerHTML)}`,
      ].join('\n')
    },
  }
}

/**
 * Identifies if an element doesn't contain child nodes (excluding comments)
 * ℹ Node.COMMENT_NODE can't be used because of the following issue 
 * https://github.com/jsdom/jsdom/issues/2220
 *
 * @param {*} element an HtmlElement or SVGElement
 * @return {*} true if the element only contains comments or none
 */
function isEmptyElement(element){
  const nonCommentChildNodes = [...element.childNodes].filter(node => node.nodeType !== 8);
  return nonCommentChildNodes.length === 0;
}

function toContainElement(container, element) {
  checkHtmlElement(container, toContainElement, this);

  if (element !== null) {
    checkHtmlElement(element, toContainElement, this);
  }

  return {
    pass: container.contains(element),
    message: () => {
      return [
        this.utils.matcherHint(
          `${this.isNot ? '.not' : ''}.toContainElement`,
          'element',
          'element',
        ),
        '',
        // eslint-disable-next-line new-cap
        this.utils.RECEIVED_COLOR(`${this.utils.stringify(
          container.cloneNode(false),
        )} ${
          this.isNot ? 'contains:' : 'does not contain:'
        } ${this.utils.stringify(element ? element.cloneNode(false) : element)}
        `),
      ].join('\n')
    },
  }
}

function getNormalizedHtml(container, htmlText) {
  const div = container.ownerDocument.createElement('div');
  div.innerHTML = htmlText;
  return div.innerHTML
}

function toContainHTML(container, htmlText) {
  checkHtmlElement(container, toContainHTML, this);

  if (typeof htmlText !== 'string') {
    throw new Error(`.toContainHTML() expects a string value, got ${htmlText}`)
  }

  return {
    pass: container.outerHTML.includes(getNormalizedHtml(container, htmlText)),
    message: () => {
      return [
        this.utils.matcherHint(
          `${this.isNot ? '.not' : ''}.toContainHTML`,
          'element',
          '',
        ),
        'Expected:',
        // eslint-disable-next-line new-cap
        `  ${this.utils.EXPECTED_COLOR(htmlText)}`,
        'Received:',
        `  ${this.utils.printReceived(container.cloneNode(true))}`,
      ].join('\n')
    },
  }
}

function toHaveTextContent(
  node,
  checkWith,
  options = {normalizeWhitespace: true},
) {
  checkNode(node, toHaveTextContent, this);

  const textContent = options.normalizeWhitespace
    ? normalize(node.textContent)
    : node.textContent.replace(/\u00a0/g, ' '); // Replace &nbsp; with normal spaces

  const checkingWithEmptyString = textContent !== '' && checkWith === '';

  return {
    pass: !checkingWithEmptyString && matches(textContent, checkWith),
    message: () => {
      const to = this.isNot ? 'not to' : 'to';
      return getMessage(
        this,
        this.utils.matcherHint(
          `${this.isNot ? '.not' : ''}.toHaveTextContent`,
          'element',
          '',
        ),
        checkingWithEmptyString
          ? `Checking with empty string will always match, use .toBeEmptyDOMElement() instead`
          : `Expected element ${to} have text content`,
        checkWith,
        'Received',
        textContent,
      )
    },
  }
}

function toHaveAccessibleDescription(
  htmlElement,
  expectedAccessibleDescription,
) {
  checkHtmlElement(htmlElement, toHaveAccessibleDescription, this);
  const actualAccessibleDescription = domAccessibilityApi.computeAccessibleDescription(htmlElement);
  const missingExpectedValue = arguments.length === 1;

  let pass = false;
  if (missingExpectedValue) {
    // When called without an expected value we only want to validate that the element has an
    // accessible description, whatever it may be.
    pass = actualAccessibleDescription !== '';
  } else {
    pass =
      expectedAccessibleDescription instanceof RegExp
        ? expectedAccessibleDescription.test(actualAccessibleDescription)
        : this.equals(
            actualAccessibleDescription,
            expectedAccessibleDescription,
          );
  }

  return {
    pass,

    message: () => {
      const to = this.isNot ? 'not to' : 'to';
      return getMessage(
        this,
        this.utils.matcherHint(
          `${this.isNot ? '.not' : ''}.${toHaveAccessibleDescription.name}`,
          'element',
          '',
        ),
        `Expected element ${to} have accessible description`,
        expectedAccessibleDescription,
        'Received',
        actualAccessibleDescription,
      )
    },
  }
}

const ariaInvalidName = 'aria-invalid';
const validStates = ['false'];

// See `aria-errormessage` spec at https://www.w3.org/TR/wai-aria-1.2/#aria-errormessage
function toHaveAccessibleErrorMessage(
  htmlElement,
  expectedAccessibleErrorMessage,
) {
  checkHtmlElement(htmlElement, toHaveAccessibleErrorMessage, this);
  const to = this.isNot ? 'not to' : 'to';
  const method = this.isNot
    ? '.not.toHaveAccessibleErrorMessage'
    : '.toHaveAccessibleErrorMessage';

  // Enforce Valid Id
  const errormessageId = htmlElement.getAttribute('aria-errormessage');
  const errormessageIdInvalid = !!errormessageId && /\s+/.test(errormessageId);

  if (errormessageIdInvalid) {
    return {
      pass: false,
      message: () => {
        return getMessage(
          this,
          this.utils.matcherHint(method, 'element'),
          "Expected element's `aria-errormessage` attribute to be empty or a single, valid ID",
          '',
          'Received',
          `aria-errormessage="${errormessageId}"`,
        )
      },
    }
  }

  // See `aria-invalid` spec at https://www.w3.org/TR/wai-aria-1.2/#aria-invalid
  const ariaInvalidVal = htmlElement.getAttribute(ariaInvalidName);
  const fieldValid =
    !htmlElement.hasAttribute(ariaInvalidName) ||
    validStates.includes(ariaInvalidVal);

  // Enforce Valid `aria-invalid` Attribute
  if (fieldValid) {
    return {
      pass: false,
      message: () => {
        return getMessage(
          this,
          this.utils.matcherHint(method, 'element'),
          'Expected element to be marked as invalid with attribute',
          `${ariaInvalidName}="${String(true)}"`,
          'Received',
          htmlElement.hasAttribute('aria-invalid')
            ? `${ariaInvalidName}="${htmlElement.getAttribute(ariaInvalidName)}`
            : null,
        )
      },
    }
  }

  const error = normalize(
    htmlElement.ownerDocument.getElementById(errormessageId)?.textContent ?? '',
  );

  return {
    pass:
      expectedAccessibleErrorMessage === undefined
        ? Boolean(error)
        : expectedAccessibleErrorMessage instanceof RegExp
        ? expectedAccessibleErrorMessage.test(error)
        : this.equals(error, expectedAccessibleErrorMessage),

    message: () => {
      return getMessage(
        this,
        this.utils.matcherHint(method, 'element'),
        `Expected element ${to} have accessible error message`,
        expectedAccessibleErrorMessage ?? '',
        'Received',
        error,
      )
    },
  }
}

const elementRoleList = buildElementRoleList(ariaQuery.elementRoles);

function toHaveRole(htmlElement, expectedRole) {
  checkHtmlElement(htmlElement, toHaveRole, this);

  const actualRoles = getExplicitOrImplicitRoles(htmlElement);
  const pass = actualRoles.some(el => el === expectedRole);

  return {
    pass,

    message: () => {
      const to = this.isNot ? 'not to' : 'to';
      return getMessage(
        this,
        this.utils.matcherHint(
          `${this.isNot ? '.not' : ''}.${toHaveRole.name}`,
          'element',
          '',
        ),
        `Expected element ${to} have role`,
        expectedRole,
        'Received',
        actualRoles.join(', '),
      )
    },
  }
}

function getExplicitOrImplicitRoles(htmlElement) {
  const hasExplicitRole = htmlElement.hasAttribute('role');

  if (hasExplicitRole) {
    const roleValue = htmlElement.getAttribute('role');

    // Handle fallback roles, such as role="switch button"
    // testing-library gates this behind the `queryFallbacks` flag; it is
    // unclear why, but it makes sense to support this pattern out of the box
    // https://testing-library.com/docs/queries/byrole/#queryfallbacks
    return roleValue.split(' ').filter(Boolean)
  }

  const implicitRoles = getImplicitAriaRoles(htmlElement);

  return implicitRoles
}

function getImplicitAriaRoles(currentNode) {
  for (const {match, roles} of elementRoleList) {
    if (match(currentNode)) {
      return [...roles]
    }
  }

  /* istanbul ignore next */
  return [] // this does not get reached in practice, since elements have at least a 'generic' role
}

/**
 * Transform the roles map (with required attributes and constraints) to a list
 * of roles. Each item in the list has functions to match an element against it.
 *
 * Essentially copied over from [dom-testing-library's
 * helpers](https://github.com/testing-library/dom-testing-library/blob/bd04cf95a1ed85a2238f7dfc1a77d5d16b4f59dc/src/role-helpers.js#L80)
 *
 * TODO: If we are truly just copying over stuff, would it make sense to move
 * this to a separate package?
 *
 * TODO: This technique relies on CSS selectors; are those consistently
 * available in all jest-dom environments? Why do other matchers in this package
 * not use them like this?
 */
function buildElementRoleList(elementRolesMap) {
  function makeElementSelector({name, attributes}) {
    return `${name}${attributes
      .map(({name: attributeName, value, constraints = []}) => {
        const shouldNotExist = constraints.indexOf('undefined') !== -1;
        if (shouldNotExist) {
          return `:not([${attributeName}])`
        } else if (value) {
          return `[${attributeName}="${value}"]`
        } else {
          return `[${attributeName}]`
        }
      })
      .join('')}`
  }

  function getSelectorSpecificity({attributes = []}) {
    return attributes.length
  }

  function bySelectorSpecificity(
    {specificity: leftSpecificity},
    {specificity: rightSpecificity},
  ) {
    return rightSpecificity - leftSpecificity
  }

  function match(element) {
    let {attributes = []} = element;

    // https://github.com/testing-library/dom-testing-library/issues/814
    const typeTextIndex = attributes.findIndex(
      attribute =>
        attribute.value &&
        attribute.name === 'type' &&
        attribute.value === 'text',
    );

    if (typeTextIndex >= 0) {
      // not using splice to not mutate the attributes array
      attributes = [
        ...attributes.slice(0, typeTextIndex),
        ...attributes.slice(typeTextIndex + 1),
      ];
    }

    const selector = makeElementSelector({...element, attributes});

    return node => {
      if (typeTextIndex >= 0 && node.type !== 'text') {
        return false
      }

      return node.matches(selector)
    }
  }

  let result = [];

  for (const [element, roles] of elementRolesMap.entries()) {
    result = [
      ...result,
      {
        match: match(element),
        roles: Array.from(roles),
        specificity: getSelectorSpecificity(element),
      },
    ];
  }

  return result.sort(bySelectorSpecificity)
}

function toHaveAccessibleName(htmlElement, expectedAccessibleName) {
  checkHtmlElement(htmlElement, toHaveAccessibleName, this);
  const actualAccessibleName = domAccessibilityApi.computeAccessibleName(htmlElement);
  const missingExpectedValue = arguments.length === 1;

  let pass = false;
  if (missingExpectedValue) {
    // When called without an expected value we only want to validate that the element has an
    // accessible name, whatever it may be.
    pass = actualAccessibleName !== '';
  } else {
    pass =
      expectedAccessibleName instanceof RegExp
        ? expectedAccessibleName.test(actualAccessibleName)
        : this.equals(actualAccessibleName, expectedAccessibleName);
  }

  return {
    pass,

    message: () => {
      const to = this.isNot ? 'not to' : 'to';
      return getMessage(
        this,
        this.utils.matcherHint(
          `${this.isNot ? '.not' : ''}.${toHaveAccessibleName.name}`,
          'element',
          '',
        ),
        `Expected element ${to} have accessible name`,
        expectedAccessibleName,
        'Received',
        actualAccessibleName,
      )
    },
  }
}

function printAttribute(stringify, name, value) {
  return value === undefined ? name : `${name}=${stringify(value)}`
}

function getAttributeComment(stringify, name, value) {
  return value === undefined
    ? `element.hasAttribute(${stringify(name)})`
    : `element.getAttribute(${stringify(name)}) === ${stringify(value)}`
}

function toHaveAttribute(htmlElement, name, expectedValue) {
  checkHtmlElement(htmlElement, toHaveAttribute, this);
  const isExpectedValuePresent = expectedValue !== undefined;
  const hasAttribute = htmlElement.hasAttribute(name);
  const receivedValue = htmlElement.getAttribute(name);
  return {
    pass: isExpectedValuePresent
      ? hasAttribute && this.equals(receivedValue, expectedValue)
      : hasAttribute,
    message: () => {
      const to = this.isNot ? 'not to' : 'to';
      const receivedAttribute = hasAttribute
        ? printAttribute(this.utils.stringify, name, receivedValue)
        : null;
      const matcher = this.utils.matcherHint(
        `${this.isNot ? '.not' : ''}.toHaveAttribute`,
        'element',
        this.utils.printExpected(name),
        {
          secondArgument: isExpectedValuePresent
            ? this.utils.printExpected(expectedValue)
            : undefined,
          comment: getAttributeComment(
            this.utils.stringify,
            name,
            expectedValue,
          ),
        },
      );
      return getMessage(
        this,
        matcher,
        `Expected the element ${to} have attribute`,
        printAttribute(this.utils.stringify, name, expectedValue),
        'Received',
        receivedAttribute,
      )
    },
  }
}

function getExpectedClassNamesAndOptions(params) {
  const lastParam = params.pop();
  let expectedClassNames, options;

  if (typeof lastParam === 'object' && !(lastParam instanceof RegExp)) {
    expectedClassNames = params;
    options = lastParam;
  } else {
    expectedClassNames = params.concat(lastParam);
    options = {exact: false};
  }
  return {expectedClassNames, options}
}

function splitClassNames(str) {
  if (!str) return []
  return str.split(/\s+/).filter(s => s.length > 0)
}

function isSubset$1(subset, superset) {
  return subset.every(strOrRegexp =>
    typeof strOrRegexp === 'string'
      ? superset.includes(strOrRegexp)
      : superset.some(className => strOrRegexp.test(className)),
  )
}

function toHaveClass(htmlElement, ...params) {
  checkHtmlElement(htmlElement, toHaveClass, this);
  const {expectedClassNames, options} = getExpectedClassNamesAndOptions(params);

  const received = splitClassNames(htmlElement.getAttribute('class'));
  const expected = expectedClassNames.reduce(
    (acc, className) =>
      acc.concat(
        typeof className === 'string' || !className
          ? splitClassNames(className)
          : className,
      ),
    [],
  );

  const hasRegExp = expected.some(className => className instanceof RegExp);
  if (options.exact && hasRegExp) {
    throw new Error('Exact option does not support RegExp expected class names')
  }

  if (options.exact) {
    return {
      pass: isSubset$1(expected, received) && expected.length === received.length,
      message: () => {
        const to = this.isNot ? 'not to' : 'to';
        return getMessage(
          this,
          this.utils.matcherHint(
            `${this.isNot ? '.not' : ''}.toHaveClass`,
            'element',
            this.utils.printExpected(expected.join(' ')),
          ),
          `Expected the element ${to} have EXACTLY defined classes`,
          expected.join(' '),
          'Received',
          received.join(' '),
        )
      },
    }
  }

  return expected.length > 0
    ? {
        pass: isSubset$1(expected, received),
        message: () => {
          const to = this.isNot ? 'not to' : 'to';
          return getMessage(
            this,
            this.utils.matcherHint(
              `${this.isNot ? '.not' : ''}.toHaveClass`,
              'element',
              this.utils.printExpected(expected.join(' ')),
            ),
            `Expected the element ${to} have class`,
            expected.join(' '),
            'Received',
            received.join(' '),
          )
        },
      }
    : {
        pass: this.isNot ? received.length > 0 : false,
        message: () =>
          this.isNot
            ? getMessage(
                this,
                this.utils.matcherHint('.not.toHaveClass', 'element', ''),
                'Expected the element to have classes',
                '(none)',
                'Received',
                received.join(' '),
              )
            : [
                this.utils.matcherHint(`.toHaveClass`, 'element'),
                'At least one expected class must be provided.',
              ].join('\n'),
      }
}

function getStyleDeclaration(document, css) {
  const styles = {};

  // The next block is necessary to normalize colors
  const copy = document.createElement('div');
  Object.keys(css).forEach(property => {
    copy.style[property] = css[property];
    styles[property] = copy.style[property];
  });

  return styles
}

function isSubset(styles, computedStyle) {
  return (
    !!Object.keys(styles).length &&
    Object.entries(styles).every(([prop, value]) => {
      const isCustomProperty = prop.startsWith('--');
      const spellingVariants = [prop];
      if (!isCustomProperty) spellingVariants.push(prop.toLowerCase());

      return spellingVariants.some(
        name =>
          computedStyle[name] === value ||
          computedStyle.getPropertyValue(name) === value,
      )
    })
  )
}

function printoutStyles(styles) {
  return Object.keys(styles)
    .sort()
    .map(prop => `${prop}: ${styles[prop]};`)
    .join('\n')
}

// Highlights only style rules that were expected but were not found in the
// received computed styles
function expectedDiff(diffFn, expected, computedStyles) {
  const received = Array.from(computedStyles)
    .filter(prop => expected[prop] !== undefined)
    .reduce(
      (obj, prop) =>
        Object.assign(obj, {[prop]: computedStyles.getPropertyValue(prop)}),
      {},
    );
  const diffOutput = diffFn(printoutStyles(expected), printoutStyles(received));
  // Remove the "+ Received" annotation because this is a one-way diff
  return diffOutput.replace(`${pico.red('+ Received')}\n`, '')
}

function toHaveStyle(htmlElement, css) {
  checkHtmlElement(htmlElement, toHaveStyle, this);
  const parsedCSS =
    typeof css === 'object' ? css : parseCSS(css, toHaveStyle, this);
  const {getComputedStyle} = htmlElement.ownerDocument.defaultView;

  const expected = getStyleDeclaration(htmlElement.ownerDocument, parsedCSS);
  const received = getComputedStyle(htmlElement);

  return {
    pass: isSubset(expected, received),
    message: () => {
      const matcher = `${this.isNot ? '.not' : ''}.toHaveStyle`;
      return [
        this.utils.matcherHint(matcher, 'element', ''),
        expectedDiff(this.utils.diff, expected, received),
      ].join('\n\n')
    },
  }
}

function toHaveFocus(element) {
  checkHtmlElement(element, toHaveFocus, this);

  return {
    pass: element.ownerDocument.activeElement === element,
    message: () => {
      return [
        this.utils.matcherHint(
          `${this.isNot ? '.not' : ''}.toHaveFocus`,
          'element',
          '',
        ),
        '',
        ...(this.isNot
          ? [
              'Received element is focused:',
              `  ${this.utils.printReceived(element)}`,
            ]
          : [
              'Expected element with focus:',
              `  ${this.utils.printExpected(element)}`,
              'Received element with focus:',
              `  ${this.utils.printReceived(
                element.ownerDocument.activeElement,
              )}`,
            ]),
      ].join('\n')
    },
  }
}

// Returns the combined value of several elements that have the same name
// e.g. radio buttons or groups of checkboxes
function getMultiElementValue(elements) {
  const types = [...new Set(elements.map(element => element.type))];
  if (types.length !== 1) {
    throw new Error(
      'Multiple form elements with the same name must be of the same type',
    )
  }
  switch (types[0]) {
    case 'radio': {
      const theChosenOne = elements.find(radio => radio.checked);
      return theChosenOne ? theChosenOne.value : undefined
    }
    case 'checkbox':
      return elements
        .filter(checkbox => checkbox.checked)
        .map(checkbox => checkbox.value)
    default:
      // NOTE: Not even sure this is a valid use case, but just in case...
      return elements.map(element => element.value)
  }
}

function getFormValue(container, name) {
  const elements = [...container.querySelectorAll(`[name="${escape(name)}"]`)];
  /* istanbul ignore if */
  if (elements.length === 0) {
    return undefined // shouldn't happen, but just in case
  }
  switch (elements.length) {
    case 1:
      return getSingleElementValue(elements[0])
    default:
      return getMultiElementValue(elements)
  }
}

// Strips the `[]` suffix off a form value name
function getPureName(name) {
  return /\[\]$/.test(name) ? name.slice(0, -2) : name
}

function getAllFormValues(container) {
  const names = Array.from(container.elements).map(element => element.name);
  return names.reduce(
    (obj, name) => ({
      ...obj,
      [getPureName(name)]: getFormValue(container, name),
    }),
    {},
  )
}

function toHaveFormValues(formElement, expectedValues) {
  checkHtmlElement(formElement, toHaveFormValues, this);
  if (!formElement.elements) {
    // TODO: Change condition to use instanceof against the appropriate element classes instead
    throw new Error('toHaveFormValues must be called on a form or a fieldset')
  }
  const formValues = getAllFormValues(formElement);
  return {
    pass: Object.entries(expectedValues).every(([name, expectedValue]) =>
      compareAsSet(formValues[name], expectedValue),
    ),
    message: () => {
      const to = this.isNot ? 'not to' : 'to';
      const matcher = `${this.isNot ? '.not' : ''}.toHaveFormValues`;
      const commonKeyValues = Object.keys(formValues)
        .filter(key => expectedValues.hasOwnProperty(key))
        .reduce((obj, key) => ({...obj, [key]: formValues[key]}), {});
      return [
        this.utils.matcherHint(matcher, 'element', ''),
        `Expected the element ${to} have form values`,
        this.utils.diff(expectedValues, commonKeyValues),
      ].join('\n\n')
    },
  }
}

function isStyleVisible(element) {
  const {getComputedStyle} = element.ownerDocument.defaultView;

  const {display, visibility, opacity} = getComputedStyle(element);
  return (
    display !== 'none' &&
    visibility !== 'hidden' &&
    visibility !== 'collapse' &&
    opacity !== '0' &&
    opacity !== 0
  )
}

function isAttributeVisible(element, previousElement) {
  let detailsVisibility;

  if (previousElement) {
    detailsVisibility =
      element.nodeName === 'DETAILS' && previousElement.nodeName !== 'SUMMARY'
        ? element.hasAttribute('open')
        : true;
  } else {
    detailsVisibility =
      element.nodeName === 'DETAILS' ? element.hasAttribute('open') : true;
  }

  return !element.hasAttribute('hidden') && detailsVisibility
}

function isElementVisible(element, previousElement) {
  return (
    isStyleVisible(element) &&
    isAttributeVisible(element, previousElement) &&
    (!element.parentElement || isElementVisible(element.parentElement, element))
  )
}

function toBeVisible(element) {
  checkHtmlElement(element, toBeVisible, this);
  const isInDocument =
    element.ownerDocument === element.getRootNode({composed: true});
  const isVisible = isInDocument && isElementVisible(element);
  return {
    pass: isVisible,
    message: () => {
      const is = isVisible ? 'is' : 'is not';
      return [
        this.utils.matcherHint(
          `${this.isNot ? '.not' : ''}.toBeVisible`,
          'element',
          '',
        ),
        '',
        `Received element ${is} visible${
          isInDocument ? '' : ' (element is not in the document)'
        }:`,
        `  ${this.utils.printReceived(element.cloneNode(false))}`,
      ].join('\n')
    },
  }
}

// form elements that support 'disabled'
const FORM_TAGS$2 = [
  'fieldset',
  'input',
  'select',
  'optgroup',
  'option',
  'button',
  'textarea',
];

/*
 * According to specification:
 * If <fieldset> is disabled, the form controls that are its descendants,
 * except descendants of its first optional <legend> element, are disabled
 *
 * https://html.spec.whatwg.org/multipage/form-elements.html#concept-fieldset-disabled
 *
 * This method tests whether element is first legend child of fieldset parent
 */
function isFirstLegendChildOfFieldset(element, parent) {
  return (
    getTag(element) === 'legend' &&
    getTag(parent) === 'fieldset' &&
    element.isSameNode(
      Array.from(parent.children).find(child => getTag(child) === 'legend'),
    )
  )
}

function isElementDisabledByParent(element, parent) {
  return (
    isElementDisabled(parent) && !isFirstLegendChildOfFieldset(element, parent)
  )
}

function isCustomElement(tag) {
  return tag.includes('-')
}

/*
 * Only certain form elements and custom elements can actually be disabled:
 * https://html.spec.whatwg.org/multipage/semantics-other.html#disabled-elements
 */
function canElementBeDisabled(element) {
  const tag = getTag(element);
  return FORM_TAGS$2.includes(tag) || isCustomElement(tag)
}

function isElementDisabled(element) {
  return canElementBeDisabled(element) && element.hasAttribute('disabled')
}

function isAncestorDisabled(element) {
  const parent = element.parentElement;
  return (
    Boolean(parent) &&
    (isElementDisabledByParent(element, parent) || isAncestorDisabled(parent))
  )
}

function isElementOrAncestorDisabled(element) {
  return (
    canElementBeDisabled(element) &&
    (isElementDisabled(element) || isAncestorDisabled(element))
  )
}

function toBeDisabled(element) {
  checkHtmlElement(element, toBeDisabled, this);

  const isDisabled = isElementOrAncestorDisabled(element);

  return {
    pass: isDisabled,
    message: () => {
      const is = isDisabled ? 'is' : 'is not';
      return [
        this.utils.matcherHint(
          `${this.isNot ? '.not' : ''}.toBeDisabled`,
          'element',
          '',
        ),
        '',
        `Received element ${is} disabled:`,
        `  ${this.utils.printReceived(element.cloneNode(false))}`,
      ].join('\n')
    },
  }
}

function toBeEnabled(element) {
  checkHtmlElement(element, toBeEnabled, this);

  const isEnabled = !isElementOrAncestorDisabled(element);

  return {
    pass: isEnabled,
    message: () => {
      const is = isEnabled ? 'is' : 'is not';
      return [
        this.utils.matcherHint(
          `${this.isNot ? '.not' : ''}.toBeEnabled`,
          'element',
          '',
        ),
        '',
        `Received element ${is} enabled:`,
        `  ${this.utils.printReceived(element.cloneNode(false))}`,
      ].join('\n')
    },
  }
}

// form elements that support 'required'
const FORM_TAGS$1 = ['select', 'textarea'];

const ARIA_FORM_TAGS = ['input', 'select', 'textarea'];

const UNSUPPORTED_INPUT_TYPES = [
  'color',
  'hidden',
  'range',
  'submit',
  'image',
  'reset',
];

const SUPPORTED_ARIA_ROLES = [
  'checkbox',
  'combobox',
  'gridcell',
  'listbox',
  'radiogroup',
  'spinbutton',
  'textbox',
  'tree',
];

function isRequiredOnFormTagsExceptInput(element) {
  return FORM_TAGS$1.includes(getTag(element)) && element.hasAttribute('required')
}

function isRequiredOnSupportedInput(element) {
  return (
    getTag(element) === 'input' &&
    element.hasAttribute('required') &&
    ((element.hasAttribute('type') &&
      !UNSUPPORTED_INPUT_TYPES.includes(element.getAttribute('type'))) ||
      !element.hasAttribute('type'))
  )
}

function isElementRequiredByARIA(element) {
  return (
    element.hasAttribute('aria-required') &&
    element.getAttribute('aria-required') === 'true' &&
    (ARIA_FORM_TAGS.includes(getTag(element)) ||
      (element.hasAttribute('role') &&
        SUPPORTED_ARIA_ROLES.includes(element.getAttribute('role'))))
  )
}

function toBeRequired(element) {
  checkHtmlElement(element, toBeRequired, this);

  const isRequired =
    isRequiredOnFormTagsExceptInput(element) ||
    isRequiredOnSupportedInput(element) ||
    isElementRequiredByARIA(element);

  return {
    pass: isRequired,
    message: () => {
      const is = isRequired ? 'is' : 'is not';
      return [
        this.utils.matcherHint(
          `${this.isNot ? '.not' : ''}.toBeRequired`,
          'element',
          '',
        ),
        '',
        `Received element ${is} required:`,
        `  ${this.utils.printReceived(element.cloneNode(false))}`,
      ].join('\n')
    },
  }
}

const FORM_TAGS = ['form', 'input', 'select', 'textarea'];

function isElementHavingAriaInvalid(element) {
  return (
    element.hasAttribute('aria-invalid') &&
    element.getAttribute('aria-invalid') !== 'false'
  )
}

function isSupportsValidityMethod(element) {
  return FORM_TAGS.includes(getTag(element))
}

function isElementInvalid(element) {
  const isHaveAriaInvalid = isElementHavingAriaInvalid(element);
  if (isSupportsValidityMethod(element)) {
    return isHaveAriaInvalid || !element.checkValidity()
  } else {
    return isHaveAriaInvalid
  }
}

function toBeInvalid(element) {
  checkHtmlElement(element, toBeInvalid, this);

  const isInvalid = isElementInvalid(element);

  return {
    pass: isInvalid,
    message: () => {
      const is = isInvalid ? 'is' : 'is not';
      return [
        this.utils.matcherHint(
          `${this.isNot ? '.not' : ''}.toBeInvalid`,
          'element',
          '',
        ),
        '',
        `Received element ${is} currently invalid:`,
        `  ${this.utils.printReceived(element.cloneNode(false))}`,
      ].join('\n')
    },
  }
}

function toBeValid(element) {
  checkHtmlElement(element, toBeValid, this);

  const isValid = !isElementInvalid(element);

  return {
    pass: isValid,
    message: () => {
      const is = isValid ? 'is' : 'is not';
      return [
        this.utils.matcherHint(
          `${this.isNot ? '.not' : ''}.toBeValid`,
          'element',
          '',
        ),
        '',
        `Received element ${is} currently valid:`,
        `  ${this.utils.printReceived(element.cloneNode(false))}`,
      ].join('\n')
    },
  }
}

function toHaveValue(htmlElement, expectedValue) {
  checkHtmlElement(htmlElement, toHaveValue, this);

  if (
    htmlElement.tagName.toLowerCase() === 'input' &&
    ['checkbox', 'radio'].includes(htmlElement.type)
  ) {
    throw new Error(
      'input with type=checkbox or type=radio cannot be used with .toHaveValue(). Use .toBeChecked() for type=checkbox or .toHaveFormValues() instead',
    )
  }

  const receivedValue = getSingleElementValue(htmlElement);
  const expectsValue = expectedValue !== undefined;

  let expectedTypedValue = expectedValue;
  let receivedTypedValue = receivedValue;
  if (expectedValue == receivedValue && expectedValue !== receivedValue) {
    expectedTypedValue = `${expectedValue} (${typeof expectedValue})`;
    receivedTypedValue = `${receivedValue} (${typeof receivedValue})`;
  }

  return {
    pass: expectsValue
      ? compareAsSet(receivedValue, expectedValue)
      : Boolean(receivedValue),
    message: () => {
      const to = this.isNot ? 'not to' : 'to';
      const matcher = this.utils.matcherHint(
        `${this.isNot ? '.not' : ''}.toHaveValue`,
        'element',
        expectedValue,
      );
      return getMessage(
        this,
        matcher,
        `Expected the element ${to} have value`,
        expectsValue ? expectedTypedValue : '(any)',
        'Received',
        receivedTypedValue,
      )
    },
  }
}

function toHaveDisplayValue(htmlElement, expectedValue) {
  checkHtmlElement(htmlElement, toHaveDisplayValue, this);
  const tagName = htmlElement.tagName.toLowerCase();

  if (!['select', 'input', 'textarea'].includes(tagName)) {
    throw new Error(
      '.toHaveDisplayValue() currently supports only input, textarea or select elements, try with another matcher instead.',
    )
  }

  if (tagName === 'input' && ['radio', 'checkbox'].includes(htmlElement.type)) {
    throw new Error(
      `.toHaveDisplayValue() currently does not support input[type="${htmlElement.type}"], try with another matcher instead.`,
    )
  }

  const values = getValues(tagName, htmlElement);
  const expectedValues = getExpectedValues(expectedValue);
  const numberOfMatchesWithValues = expectedValues.filter(expected =>
    values.some(value =>
      expected instanceof RegExp
        ? expected.test(value)
        : this.equals(value, String(expected)),
    ),
  ).length;

  const matchedWithAllValues = numberOfMatchesWithValues === values.length;
  const matchedWithAllExpectedValues =
    numberOfMatchesWithValues === expectedValues.length;

  return {
    pass: matchedWithAllValues && matchedWithAllExpectedValues,
    message: () =>
      getMessage(
        this,
        this.utils.matcherHint(
          `${this.isNot ? '.not' : ''}.toHaveDisplayValue`,
          'element',
          '',
        ),
        `Expected element ${this.isNot ? 'not ' : ''}to have display value`,
        expectedValue,
        'Received',
        values,
      ),
  }
}

function getValues(tagName, htmlElement) {
  return tagName === 'select'
    ? Array.from(htmlElement)
        .filter(option => option.selected)
        .map(option => option.textContent)
    : [htmlElement.value]
}

function getExpectedValues(expectedValue) {
  return expectedValue instanceof Array ? expectedValue : [expectedValue]
}

function toBeChecked(element) {
  checkHtmlElement(element, toBeChecked, this);

  const isValidInput = () => {
    return (
      element.tagName.toLowerCase() === 'input' &&
      ['checkbox', 'radio'].includes(element.type)
    )
  };

  const isValidAriaElement = () => {
    return (
      roleSupportsChecked(element.getAttribute('role')) &&
      ['true', 'false'].includes(element.getAttribute('aria-checked'))
    )
  };

  if (!isValidInput() && !isValidAriaElement()) {
    return {
      pass: false,
      message: () =>
        `only inputs with type="checkbox" or type="radio" or elements with ${supportedRolesSentence()} and a valid aria-checked attribute can be used with .toBeChecked(). Use .toHaveValue() instead`,
    }
  }

  const isChecked = () => {
    if (isValidInput()) return element.checked
    return element.getAttribute('aria-checked') === 'true'
  };

  return {
    pass: isChecked(),
    message: () => {
      const is = isChecked() ? 'is' : 'is not';
      return [
        this.utils.matcherHint(
          `${this.isNot ? '.not' : ''}.toBeChecked`,
          'element',
          '',
        ),
        '',
        `Received element ${is} checked:`,
        `  ${this.utils.printReceived(element.cloneNode(false))}`,
      ].join('\n')
    },
  }
}

function supportedRolesSentence() {
  return toSentence(
    supportedRoles().map(role => `role="${role}"`),
    {lastWordConnector: ' or '},
  )
}

function supportedRoles() {
  return ariaQuery.roles.keys().filter(roleSupportsChecked)
}

function roleSupportsChecked(role) {
  return ariaQuery.roles.get(role)?.props['aria-checked'] !== undefined
}

function toBePartiallyChecked(element) {
  checkHtmlElement(element, toBePartiallyChecked, this);

  const isValidInput = () => {
    return (
      element.tagName.toLowerCase() === 'input' && element.type === 'checkbox'
    )
  };

  const isValidAriaElement = () => {
    return element.getAttribute('role') === 'checkbox'
  };

  if (!isValidInput() && !isValidAriaElement()) {
    return {
      pass: false,
      message: () =>
        'only inputs with type="checkbox" or elements with role="checkbox" and a valid aria-checked attribute can be used with .toBePartiallyChecked(). Use .toHaveValue() instead',
    }
  }

  const isPartiallyChecked = () => {
    const isAriaMixed = element.getAttribute('aria-checked') === 'mixed';

    if (isValidInput()) {
      return element.indeterminate || isAriaMixed
    }

    return isAriaMixed
  };

  return {
    pass: isPartiallyChecked(),
    message: () => {
      const is = isPartiallyChecked() ? 'is' : 'is not';
      return [
        this.utils.matcherHint(
          `${this.isNot ? '.not' : ''}.toBePartiallyChecked`,
          'element',
          '',
        ),
        '',
        `Received element ${is} partially checked:`,
        `  ${this.utils.printReceived(element.cloneNode(false))}`,
      ].join('\n')
    },
  }
}

// See algoritm: https://www.w3.org/TR/accname-1.1/#mapping_additional_nd_description
function toHaveDescription(htmlElement, checkWith) {
  deprecate(
    'toHaveDescription',
    'Please use toHaveAccessibleDescription.',
  );

  checkHtmlElement(htmlElement, toHaveDescription, this);

  const expectsDescription = checkWith !== undefined;

  const descriptionIDRaw = htmlElement.getAttribute('aria-describedby') || '';
  const descriptionIDs = descriptionIDRaw.split(/\s+/).filter(Boolean);
  let description = '';
  if (descriptionIDs.length > 0) {
    const document = htmlElement.ownerDocument;
    const descriptionEls = descriptionIDs
      .map(descriptionID => document.getElementById(descriptionID))
      .filter(Boolean);
    description = normalize(descriptionEls.map(el => el.textContent).join(' '));
  }

  return {
    pass: expectsDescription
      ? checkWith instanceof RegExp
        ? checkWith.test(description)
        : this.equals(description, checkWith)
      : Boolean(description),
    message: () => {
      const to = this.isNot ? 'not to' : 'to';
      return getMessage(
        this,
        this.utils.matcherHint(
          `${this.isNot ? '.not' : ''}.toHaveDescription`,
          'element',
          '',
        ),
        `Expected the element ${to} have description`,
        this.utils.printExpected(checkWith),
        'Received',
        this.utils.printReceived(description),
      )
    },
  }
}

// See aria-errormessage spec https://www.w3.org/TR/wai-aria-1.2/#aria-errormessage
function toHaveErrorMessage(htmlElement, checkWith) {
  deprecate('toHaveErrorMessage', 'Please use toHaveAccessibleErrorMessage.');
  checkHtmlElement(htmlElement, toHaveErrorMessage, this);

  if (
    !htmlElement.hasAttribute('aria-invalid') ||
    htmlElement.getAttribute('aria-invalid') === 'false'
  ) {
    const not = this.isNot ? '.not' : '';

    return {
      pass: false,
      message: () => {
        return getMessage(
          this,
          this.utils.matcherHint(`${not}.toHaveErrorMessage`, 'element', ''),
          `Expected the element to have invalid state indicated by`,
          'aria-invalid="true"',
          'Received',
          htmlElement.hasAttribute('aria-invalid')
            ? `aria-invalid="${htmlElement.getAttribute('aria-invalid')}"`
            : this.utils.printReceived(''),
        )
      },
    }
  }

  const expectsErrorMessage = checkWith !== undefined;

  const errormessageIDRaw = htmlElement.getAttribute('aria-errormessage') || '';
  const errormessageIDs = errormessageIDRaw.split(/\s+/).filter(Boolean);

  let errormessage = '';
  if (errormessageIDs.length > 0) {
    const document = htmlElement.ownerDocument;

    const errormessageEls = errormessageIDs
      .map(errormessageID => document.getElementById(errormessageID))
      .filter(Boolean);

    errormessage = normalize(
      errormessageEls.map(el => el.textContent).join(' '),
    );
  }

  return {
    pass: expectsErrorMessage
      ? checkWith instanceof RegExp
        ? checkWith.test(errormessage)
        : this.equals(errormessage, checkWith)
      : Boolean(errormessage),
    message: () => {
      const to = this.isNot ? 'not to' : 'to';
      return getMessage(
        this,
        this.utils.matcherHint(
          `${this.isNot ? '.not' : ''}.toHaveErrorMessage`,
          'element',
          '',
        ),
        `Expected the element ${to} have error message`,
        this.utils.printExpected(checkWith),
        'Received',
        this.utils.printReceived(errormessage),
      )
    },
  }
}

/**
 * Returns the selection from the element.
 *
 * @param element {HTMLElement} The element to get the selection from.
 * @returns {String} The selection.
 */
function getSelection(element) {
  const selection = element.ownerDocument.getSelection();

  if (['input', 'textarea'].includes(element.tagName.toLowerCase())) {
    if (['radio', 'checkbox'].includes(element.type)) return ''
    return element.value
      .toString()
      .substring(element.selectionStart, element.selectionEnd)
  }

  if (selection.anchorNode === null || selection.focusNode === null) {
    // No selection
    return ''
  }

  const originalRange = selection.getRangeAt(0);
  const temporaryRange = element.ownerDocument.createRange();

  if (selection.containsNode(element, false)) {
    // Whole element is inside selection
    temporaryRange.selectNodeContents(element);
    selection.removeAllRanges();
    selection.addRange(temporaryRange);
  } else if (
    element.contains(selection.anchorNode) &&
    element.contains(selection.focusNode)
  ) ; else {
    // Element is partially selected
    const selectionStartsWithinElement =
      element === originalRange.startContainer ||
      element.contains(originalRange.startContainer);
    const selectionEndsWithinElement =
      element === originalRange.endContainer ||
      element.contains(originalRange.endContainer);
    selection.removeAllRanges();

    if (selectionStartsWithinElement || selectionEndsWithinElement) {
      temporaryRange.selectNodeContents(element);

      if (selectionStartsWithinElement) {
        temporaryRange.setStart(
          originalRange.startContainer,
          originalRange.startOffset,
        );
      }
      if (selectionEndsWithinElement) {
        temporaryRange.setEnd(
          originalRange.endContainer,
          originalRange.endOffset,
        );
      }

      selection.addRange(temporaryRange);
    }
  }

  const result = selection.toString();

  selection.removeAllRanges();
  selection.addRange(originalRange);

  return result
}

/**
 * Checks if the element has the string selected.
 *
 * @param htmlElement {HTMLElement} The html element to check the selection for.
 * @param expectedSelection {String} The selection as a string.
 */
function toHaveSelection(htmlElement, expectedSelection) {
  checkHtmlElement(htmlElement, toHaveSelection, this);

  const expectsSelection = expectedSelection !== undefined;

  if (expectsSelection && typeof expectedSelection !== 'string') {
    throw new Error(`expected selection must be a string or undefined`)
  }

  const receivedSelection = getSelection(htmlElement);

  return {
    pass: expectsSelection
      ? compareAsSet(receivedSelection, expectedSelection)
      : Boolean(receivedSelection),
    message: () => {
      const to = this.isNot ? 'not to' : 'to';
      const matcher = this.utils.matcherHint(
        `${this.isNot ? '.not' : ''}.toHaveSelection`,
        'element',
        expectedSelection,
      );
      return getMessage(
        this,
        matcher,
        `Expected the element ${to} have selection`,
        expectsSelection ? expectedSelection : '(any)',
        'Received',
        receivedSelection,
      )
    },
  }
}

function toBePressed(element) {
  checkHtmlElement(element, toBePressed, this);

  const roles = (element.getAttribute('role') || '')
    .split(' ')
    .map(role => role.trim());

  const isButton =
    element.tagName.toLowerCase() === 'button' ||
    (element.tagName.toLowerCase() === 'input' && element.type === 'button') ||
    roles.includes('button');

  const pressedAttribute = element.getAttribute('aria-pressed');

  const isValidAriaElement =
    pressedAttribute === 'true' || pressedAttribute === 'false';

  if (!isButton || !isValidAriaElement) {
    return {
      pass: false,
      message: () =>
        `Only button or input with type="button" or element with role="button" and a valid aria-pressed attribute can be used with .toBePressed()`,
    }
  }

  const isPressed = pressedAttribute === 'true';

  return {
    pass: isButton && isPressed,

    message: () => {
      const matcher = this.utils.matcherHint(
        `${this.isNot ? '.not' : ''}.toBePressed`,
        'element',
        '',
      );

      return getMessage(
        this,
        matcher,
        `Expected element to have`,
        `aria-pressed="${this.isNot ? 'false' : 'true'}"`,
        `Received`,
        `aria-pressed="${pressedAttribute}"`,
      )
    },
  }
}

function toBePartiallyPressed(element) {
  checkHtmlElement(element, toBePartiallyPressed, this);

  const roles = (element.getAttribute('role') || '')
    .split(' ')
    .map(role => role.trim());

  const isButton =
    element.tagName.toLowerCase() === 'button' ||
    (element.tagName.toLowerCase() === 'input' && element.type === 'button') ||
    roles.includes('button');

  const pressedAttribute = element.getAttribute('aria-pressed');

  const isValidAriaElement =
    pressedAttribute === 'true' ||
    pressedAttribute === 'false' ||
    pressedAttribute === 'mixed';

  if (!isButton || !isValidAriaElement) {
    return {
      pass: false,
      message: () =>
        `Only button or input with type="button" or element with role="button" and a valid aria-pressed attribute can be used with .toBePartiallyPressed()`,
    }
  }

  const isPartiallyPressed = pressedAttribute === 'mixed';

  return {
    pass: isButton && isPartiallyPressed,

    message: () => {
      const to = this.isNot ? 'not to' : 'to';

      const matcher = this.utils.matcherHint(
        `${this.isNot ? '.not' : ''}.toBePartiallyPressed`,
        'element',
        '',
      );

      return getMessage(
        this,
        matcher,
        `Expected element ${to} have`,
        `aria-pressed="mixed"`,
        `Received`,
        `aria-pressed="${pressedAttribute}"`,
      )
    },
  }
}

// ref: https://dom.spec.whatwg.org/#dom-node-document_position_disconnected
const DOCUMENT_POSITION_DISCONNECTED = 0x01;
const DOCUMENT_POSITION_PRECEDING = 0x02;
const DOCUMENT_POSITION_FOLLOWING = 0x04;
const DOCUMENT_POSITION_CONTAINS = 0x08;
const DOCUMENT_POSITION_CONTAINED_BY = 0x10;
const DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC = 0x20;

// ref: https://developer.mozilla.org/en-US/docs/Web/API/Node/compareDocumentPosition
const DOCUMENT_POSITIONS_STRINGS = {
  [DOCUMENT_POSITION_DISCONNECTED]: 'Node.DOCUMENT_POSITION_DISCONNECTED',
  [DOCUMENT_POSITION_PRECEDING]: 'Node.DOCUMENT_POSITION_PRECEDING',
  [DOCUMENT_POSITION_FOLLOWING]: 'Node.DOCUMENT_POSITION_FOLLOWING',
  [DOCUMENT_POSITION_CONTAINS]: 'Node.DOCUMENT_POSITION_CONTAINS',
  [DOCUMENT_POSITION_CONTAINED_BY]: 'Node.DOCUMENT_POSITION_CONTAINED_BY',
  [DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC]:
    'Node.DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC',
};

function makeDocumentPositionErrorString(documentPosition) {
  if (documentPosition in DOCUMENT_POSITIONS_STRINGS) {
    return `${DOCUMENT_POSITIONS_STRINGS[documentPosition]} (${documentPosition})`
  }

  return `Unknown document position (${documentPosition})`
}

function checkToAppear(methodName, targetDocumentPosition) {
  // eslint-disable-next-line func-names
  return function (element, secondElement) {
    checkHtmlElement(element, toAppearBefore, this);
    checkHtmlElement(secondElement, toAppearBefore, this);

    const documentPosition = element.compareDocumentPosition(secondElement);
    const pass = documentPosition === targetDocumentPosition;

    return {
      pass,
      message: () => {
        return [
          this.utils.matcherHint(
            `${this.isNot ? '.not' : ''}.${methodName}`,
            'element',
            'secondElement',
          ),
          '',
          `Received: ${makeDocumentPositionErrorString(documentPosition)}`,
        ].join('\n')
      },
    }
  }
}

function toAppearBefore(element, secondElement) {
  return checkToAppear('toAppearBefore', DOCUMENT_POSITION_FOLLOWING).apply(
    this,
    [element, secondElement],
  )
}

function toAppearAfter(element, secondElement) {
  return checkToAppear('toAppearAfter', DOCUMENT_POSITION_PRECEDING).apply(
    this,
    [element, secondElement],
  )
}

var extensions = /*#__PURE__*/Object.freeze({
  __proto__: null,
  toAppearAfter: toAppearAfter,
  toAppearBefore: toAppearBefore,
  toBeChecked: toBeChecked,
  toBeDisabled: toBeDisabled,
  toBeEmpty: toBeEmpty,
  toBeEmptyDOMElement: toBeEmptyDOMElement,
  toBeEnabled: toBeEnabled,
  toBeInTheDOM: toBeInTheDOM,
  toBeInTheDocument: toBeInTheDocument,
  toBeInvalid: toBeInvalid,
  toBePartiallyChecked: toBePartiallyChecked,
  toBePartiallyPressed: toBePartiallyPressed,
  toBePressed: toBePressed,
  toBeRequired: toBeRequired,
  toBeValid: toBeValid,
  toBeVisible: toBeVisible,
  toContainElement: toContainElement,
  toContainHTML: toContainHTML,
  toHaveAccessibleDescription: toHaveAccessibleDescription,
  toHaveAccessibleErrorMessage: toHaveAccessibleErrorMessage,
  toHaveAccessibleName: toHaveAccessibleName,
  toHaveAttribute: toHaveAttribute,
  toHaveClass: toHaveClass,
  toHaveDescription: toHaveDescription,
  toHaveDisplayValue: toHaveDisplayValue,
  toHaveErrorMessage: toHaveErrorMessage,
  toHaveFocus: toHaveFocus,
  toHaveFormValues: toHaveFormValues,
  toHaveRole: toHaveRole,
  toHaveSelection: toHaveSelection,
  toHaveStyle: toHaveStyle,
  toHaveTextContent: toHaveTextContent,
  toHaveValue: toHaveValue
});

exports.extensions = extensions;
exports.toAppearAfter = toAppearAfter;
exports.toAppearBefore = toAppearBefore;
exports.toBeChecked = toBeChecked;
exports.toBeDisabled = toBeDisabled;
exports.toBeEmpty = toBeEmpty;
exports.toBeEmptyDOMElement = toBeEmptyDOMElement;
exports.toBeEnabled = toBeEnabled;
exports.toBeInTheDOM = toBeInTheDOM;
exports.toBeInTheDocument = toBeInTheDocument;
exports.toBeInvalid = toBeInvalid;
exports.toBePartiallyChecked = toBePartiallyChecked;
exports.toBePartiallyPressed = toBePartiallyPressed;
exports.toBePressed = toBePressed;
exports.toBeRequired = toBeRequired;
exports.toBeValid = toBeValid;
exports.toBeVisible = toBeVisible;
exports.toContainElement = toContainElement;
exports.toContainHTML = toContainHTML;
exports.toHaveAccessibleDescription = toHaveAccessibleDescription;
exports.toHaveAccessibleErrorMessage = toHaveAccessibleErrorMessage;
exports.toHaveAccessibleName = toHaveAccessibleName;
exports.toHaveAttribute = toHaveAttribute;
exports.toHaveClass = toHaveClass;
exports.toHaveDescription = toHaveDescription;
exports.toHaveDisplayValue = toHaveDisplayValue;
exports.toHaveErrorMessage = toHaveErrorMessage;
exports.toHaveFocus = toHaveFocus;
exports.toHaveFormValues = toHaveFormValues;
exports.toHaveRole = toHaveRole;
exports.toHaveSelection = toHaveSelection;
exports.toHaveStyle = toHaveStyle;
exports.toHaveTextContent = toHaveTextContent;
exports.toHaveValue = toHaveValue;
