/**
 * Checks if the target is the same as the provided element of that the element
 * contains the target. Returns true is this is the case.
 *
 * @returns boolean
 */
export const isElement = (element, target) => {
  return element !== null && (element === target || element.contains(target))
}

/**
 * Checks if the provided object is an html dom element.
 *
 * @returns boolean
 */
export const isDomElement = (obj) => {
  try {
    return obj instanceof HTMLElement
  } catch (e) {
    return (
      typeof obj === 'object' &&
      obj.nodeType === 1 &&
      typeof obj.style === 'object' &&
      typeof obj.ownerDocument === 'object'
    )
  }
}

/**
 * This function will focus a contenteditable and place the cursor at the end.
 *
 * @param element
 */
export const focusEnd = (element) => {
  const range = document.createRange()
  const selection = window.getSelection()
  range.selectNodeContents(element)
  range.collapse(false)
  selection.removeAllRanges()
  selection.addRange(range)
  element.focus()
}

/**
 * Get the closest parent element that matches a predicate function.
 *
 * @param {Element} element - The starting element.
 * @param {Function} predicate - A function that takes an element and returns a boolean.
 * @returns {Element|null} The matching parent element or null if no match is found.
 */
export const getParentMatchingPredicate = (element, predicate) => {
  while (element !== null && element.nodeType === Node.ELEMENT_NODE) {
    if (predicate(element)) {
      return element
    }
    element = element.parentElement
  }
  return null
}

/**
 * Finds the closest scrollable parent element of the provided element.
 */
export const findScrollableParent = (element) => {
  return getParentMatchingPredicate(
    element,
    (element) => element.scrollHeight > element.clientHeight
  )
}

/**
 * Detects clicks outside el element and call callback
 *
 * Returns a callback to unregister click handlers after successful outside click
 * @param el
 * @param callback
 * @returns {(function(): void)|*}
 */
export const onClickOutside = (el, callback) => {
  const insideEvent = new Set()

  // Firefox and Chrome both can both have a different `target` element on `click`
  // when you release the mouse at different coordinates. Therefore we expect this
  // variable to be set on mousedown to be consistent.
  let downElement = null

  // Add the event to the `insideEvent` map. This allow to be sure a click event has
  // been triggered from an element inside this context, even if the element has
  // been removed after in the meantime.
  const clickOutsideClickEvent = (event) => {
    insideEvent.add(event)
  }
  el.addEventListener('click', clickOutsideClickEvent)

  const clickOutsideMouseDownEvent = (event) => {
    downElement = event.target
  }
  document.body.addEventListener('mousedown', clickOutsideMouseDownEvent)

  const clickOutsideEvent = (event) => {
    const target = downElement || event.target

    // If the event is from current context or any element inside current context
    // the current event should be in the insideEvent map, even if the element
    // has been removed from the DOM in the meantime
    const insideContext = insideEvent.has(event)
    if (insideContext) {
      insideEvent.delete(event)
    }

    // If the click was outside the context element because we want to ignore
    // clicks inside it or any child of this element
    if (!isElement(el, target) && !insideContext) {
      callback(target, event)
    }
  }
  document.body.addEventListener('click', clickOutsideEvent)

  return () => {
    el.removeEventListener('click', clickOutsideClickEvent)
    document.body.removeEventListener('mousedown', clickOutsideMouseDownEvent)
    document.body.removeEventListener('click', clickOutsideEvent)
  }
}

/**
 * Return whether one of the ancestors of the given node matches the given predicate.
 *
 * @param {DomElement} node The node to start the search for.
 * @param {function} predicate The predicate to test on every ancestor.
 * @param {DomElement} stop If provided, the search will stop when this element is met.
 *   It should an ancestor of the given node.
 * @returns Whether one of the ancestors matches the predicate.
 */
export const doesAncestorMatchPredicate = (node, predicate, stop) => {
  while (node != null) {
    if (stop === node) {
      return false
    }
    if (predicate(node)) {
      return true
    }
    node = node.parentNode
  }
  return false
}

/**
 * Checks a given predicate on all intermediate DOM elements between a descendant and its ancestor.
 *
 * @param {HTMLElement} ancestor - The ancestor DOM element.
 * @param {HTMLElement} descendant - The descendant DOM element. The function assumes that
 *                                   this is a descendant of the ancestor element.
 * @param {Function} predicate - A function that takes a DOM element as an argument and returns
 *                               a boolean. This function is called on each intermediate element
 *                               between the descendant and the ancestor.
 * @returns {boolean} - Returns true if the predicate returns true for at least one intermediate
 *                      element between the descendant and ancestor. Otherwise, returns false.
 *
 * @example
 * // Example usage
 * const ancestorElement = document.getElementById('parent');
 * const descendantElement = document.getElementById('child');
 * const result = checkIntermediateElements(ancestorElement, descendantElement, (el) => {
 *   return el.tagName === 'DIV';
 * });
 * console.log(result); // Outputs true if any intermediate element is a <div>, otherwise false.
 */
export const checkIntermediateElements = (ancestor, descendant, predicate) => {
  for (let el = descendant; el && el !== ancestor; el = el.parentElement) {
    if (predicate(el)) {
      return true
    }
  }
  return false
}