import log from 'loglevel'

export type CallbackFunction<E extends Element> = (element: E) => void
export type Selectors = string | string[]

let calledIndex = 0

class Callback<E extends Element> {
  #root
  #called
  #selectors
  #fn
  #matches

  constructor(root: ParentNode, selectors: Selectors, fn: CallbackFunction<E>) {
    this.#root = root
    this.#called = `data-ofc${calledIndex++}`

    if (!Array.isArray(selectors)) {
      selectors = [selectors]
    }

    this.#selectors = selectors
      .map(
        selectors.some((s) => s.indexOf(',') > -1)
          ? (s) => s
          : (s) => `${s}:not([${this.#called}])`,
      )
      .join(',')

    this.#fn = fn

    const matches = selectors
      .map((s) => s.trim())
      .filter((s) => s.indexOf(' ') < 0 && s.indexOf(',') < 0)

    if (matches.length === selectors.length) {
      this.#matches = matches
    }
  }

  addTriggerElements(triggerElements: Element[], element: Element) {
    if (this.#root.contains(element)) {
      if (element.matches(this.#selectors)) {
        triggerElements.push(element)
      }

      for (const e of element.querySelectorAll(this.#selectors)) {
        triggerElements.push(e)
      }
    }
  }

  trigger(elements?: E[]) {
    if (document.readyState === 'loading') {
      return
    }

    if (this.#matches && Array.isArray(elements)) {
      for (const element of elements) {
        if (this.#matches.some((m) => element.matches(m))) {
          this.#callCallback(element)
        }
      }
    } else {
      for (const element of this.#root.querySelectorAll<E>(this.#selectors)) {
        this.#callCallback(element)
      }
    }
  }

  #callCallback(element: E) {
    if (inBlacklist(element)) {
      return
    }

    if (!element.hasAttribute(this.#called)) {
      element.setAttribute(this.#called, '')

      try {
        this.#fn(element)
      } catch (error) {
        log.error('Failed callback!', element, error)
      }
    }
  }
}

let triggerPaused = false
let triggerOnResume = false
let triggerAll = false
let triggerElements: Element[] = []
let triggerFrame: number
const callbacks: Callback<Element>[] = []

function inBlacklist(node: Node) {
  if (!(node instanceof Element)) return false

  return (
    node.closest('.CIG-wordCount') !== null ||
    node.closest('.ContentEdit-previewDisplay') !== null ||
    node.closest('.ContentLabel') !== null ||
    node.closest('.PreviewFrame-typeDisplay') !== null ||
    node.closest('.ProseMirrorToolbar') !== null ||
    node.closest('.ProseMirror') !== null ||
    node.closest('.ProseMirrorContainer') !== null ||
    node.closest('.ProseMirror-fullscreen-container') !== null ||
    node.closest('.ProseMirror-comments') !== null ||
    node.closest('.RichTextEditor') !== null ||
    node.closest('.WorkInProgressSaveStatus') !== null ||
    node.closest('#aria-live-container') !== null ||
    node.closest('.widget-urlsAutomatic') !== null
  )
}

const every = Array.prototype.every
const isText = (node: Node) => node.nodeType === Node.TEXT_NODE

function triggerCallbacks(mutations?: MutationRecord[]) {
  if (triggerPaused) {
    triggerOnResume = true
    return
  }

  const checkMutations = Array.isArray(mutations)
  const validMutations = []

  if (checkMutations) {
    for (const mutation of mutations) {
      const target = mutation.target

      if (inBlacklist(target)) {
        continue
      }

      switch (mutation.type) {
        case 'attributes':
          if (
            mutation.oldValue !==
            (target as Element).getAttribute(mutation.attributeName as string)
          ) {
            validMutations.push(mutation)
          }
          break
        case 'childList':
          if (
            !every.call(mutation.addedNodes, isText) ||
            !every.call(mutation.removedNodes, isText)
          ) {
            validMutations.push(mutation)
          }
          break
      }
    }

    if (validMutations.length === 0) {
      return
    }
  }

  if (validMutations.length > 500) {
    triggerAll = true
  }

  if (!triggerAll) {
    if (checkMutations) {
      for (const mutation of validMutations) {
        const element = mutation.target as Element

        switch (mutation.type) {
          case 'attributes':
            triggerElements.push(element)
            break
          case 'childList':
            for (const callback of callbacks) {
              callback.addTriggerElements(triggerElements, element)
            }
            break
        }
      }
    } else {
      triggerAll = true
    }
  }

  if ((triggerAll || triggerElements.length > 0) && !triggerFrame) {
    triggerFrame = window.requestAnimationFrame(() => {
      const elements = triggerAll ? undefined : triggerElements
      triggerAll = false
      triggerElements = []
      triggerFrame = 0

      callbacks.forEach((callback) => callback.trigger(elements))
    })
  }
}

function initialize() {
  document.documentElement.hidden = false
  triggerCallbacks()

  new MutationObserver(triggerCallbacks).observe(document, {
    attributes: true,
    attributeFilter: [
      'class',
      'data-bsp-autosubmit',
      'data-chart-type',
      'data-code-type',
      'data-internal-name',
      'data-tab',
      'name',
      'rel',
      'target',
    ],
    attributeOldValue: true,
    childList: true,
    subtree: true,
  })
}

document.documentElement.hidden = true

if (document.readyState === 'loading') {
  document.addEventListener('DOMContentLoaded', initialize)
} else {
  initialize()
}

function onFind<E extends Element>(
  root: ParentNode,
  selectors: Selectors,
  fn: CallbackFunction<E>,
): void

function onFind<E extends Element>(
  selectors: Selectors,
  fn: CallbackFunction<E>,
): void

function onFind<E extends Element>(
  rootOrSelectors: ParentNode | Selectors,
  selectorsOrFn: Selectors | CallbackFunction<E>,
  fn?: CallbackFunction<E>,
): void {
  let root: ParentNode
  let selectors: Selectors

  if (typeof fn !== 'undefined') {
    root = rootOrSelectors as Element
    selectors = selectorsOrFn as Selectors
  } else {
    root = document
    selectors = rootOrSelectors as Selectors
    fn = selectorsOrFn as CallbackFunction<E>
  }

  const callback = new Callback(root, selectors, fn)
  callback.trigger()
  callbacks.push(callback as Callback<Element>)
}

onFind.triggerCallbacks = triggerCallbacks

onFind.pause = () => {
  triggerPaused = true
}

onFind.resume = () => {
  triggerPaused = false

  if (triggerOnResume) {
    triggerOnResume = false
    setTimeout(triggerCallbacks, 100)
  }
}

export default onFind
