import { isObject } from '../is/is.js'

let id = 0
let offsetBase = void 0

function defaultCancel() {
  return false
}

function getAbsolutePosition(el, resize) {
  if (offsetBase === void 0) {
    offsetBase = document.createElement('div')
    offsetBase.style.cssText = 'position: absolute; left: 0; top: 0'
    document.body.append(offsetBase)
  }

  const boundingRect = el.getBoundingClientRect()
  const baseRect = offsetBase.getBoundingClientRect()
  const { marginLeft, marginRight, marginTop, marginBottom } =
    window.getComputedStyle(el)
  const marginH =
    Number.parseInt(marginLeft, 10) + Number.parseInt(marginRight, 10)
  const marginV =
    Number.parseInt(marginTop, 10) + Number.parseInt(marginBottom, 10)

  return {
    left: boundingRect.left - baseRect.left,
    top: boundingRect.top - baseRect.top,
    width: boundingRect.right - boundingRect.left,
    height: boundingRect.bottom - boundingRect.top,
    widthM: boundingRect.right - boundingRect.left + (resize ? 0 : marginH),
    heightM: boundingRect.bottom - boundingRect.top + (resize ? 0 : marginV),
    marginH: resize ? marginH : 0,
    marginV: resize ? marginV : 0
  }
}

function getAbsoluteSize(el) {
  return {
    width: el.scrollWidth,
    height: el.scrollHeight
  }
}

// firefox rulez
const styleEdges = ['Top', 'Right', 'Bottom', 'Left']
const styleBorderRadiuses = [
  'borderTopLeftRadius',
  'borderTopRightRadius',
  'borderBottomRightRadius',
  'borderBottomLeftRadius'
]
const reStyleSkipKey = /-block|-inline|block-|inline-/
const reStyleSkipRule = /(-block|-inline|block-|inline-).*:/

function getComputedStyle(el, props) {
  const style = window.getComputedStyle(el)
  const fixed = {}
  for (let i = 0; i < props.length; i++) {
    const prop = props[i]

    if (style[prop] === '') {
      if (prop === 'cssText') {
        const styleLen = style.length
        let val = ''

        for (let styleIndex = 0; styleIndex < styleLen; styleIndex++) {
          if (!reStyleSkipKey.test(style[styleIndex])) {
            val += style[styleIndex] + ': ' + style[style[styleIndex]] + '; '
          }
        }

        fixed[prop] = val
      } else if (['borderWidth', 'borderStyle', 'borderColor'].includes(prop)) {
        const suffix = prop.replace('border', '')
        let val = ''
        for (let j = 0; j < styleEdges.length; j++) {
          const subProp = 'border' + styleEdges[j] + suffix
          val += style[subProp] + ' '
        }
        fixed[prop] = val
      } else if (prop === 'borderRadius') {
        let val1 = ''
        let val2 = ''
        for (let j = 0; j < styleBorderRadiuses.length; j++) {
          const val = style[styleBorderRadiuses[j]].split(' ')
          val1 += val[0] + ' '
          val2 += (val[1] === void 0 ? val[0] : val[1]) + ' '
        }
        fixed[prop] = val1 + '/ ' + val2
      } else {
        fixed[prop] = style[prop]
      }
    } else if (prop === 'cssText') {
      fixed[prop] = style[prop]
        .split(';')
        .filter(val => !reStyleSkipRule.test(val))
        .join(';')
    } else {
      fixed[prop] = style[prop]
    }
  }

  return fixed
}

const zIndexPositions = ['absolute', 'fixed', 'relative', 'sticky']

function getMaxZIndex(elStart) {
  let el = elStart
  let maxIndex = 0

  while (el !== null && el !== document) {
    const { position, zIndex } = window.getComputedStyle(el)
    const zIndexNum = Number(zIndex)

    if (
      zIndexNum > maxIndex &&
      (el === elStart || zIndexPositions.includes(position))
    ) {
      maxIndex = zIndexNum
    }

    el = el.parentNode
  }

  return maxIndex
}

function normalizeElements(opts) {
  return {
    from: opts.from,
    to: opts.to !== void 0 ? opts.to : opts.from
  }
}

function getOpacity(value, fallback) {
  const parsed = Number.parseFloat(value)
  return Number.isFinite(parsed) ? Math.max(0, Math.min(1, parsed)) : fallback
}

function normalizeOptions(options) {
  if (typeof options === 'number') {
    options = {
      duration: options
    }
  } else if (typeof options === 'function') {
    options = {
      onEnd: options
    }
  }

  const parsedDuration = Number.parseInt(options.duration, 10)
  const parsedDelay = Number.parseInt(options.delay, 10)

  return {
    ...options,

    waitFor: options.waitFor === void 0 ? 0 : options.waitFor,

    duration: Number.isNaN(parsedDuration) ? 300 : parsedDuration,
    delay: Number.isNaN(parsedDelay) ? 0 : parsedDelay,
    easing:
      typeof options.easing === 'string' && options.easing.length !== 0
        ? options.easing
        : 'ease-in-out',
    fill:
      typeof options.fill === 'string' && options.fill.length !== 0
        ? options.fill
        : 'none',

    resize: options.resize === true,

    // account for UMD too where modifiers will be lowercased to work
    useCSS: options.useCSS === true || options.usecss === true,
    // account for UMD too where modifiers will be lowercased to work
    hideFromClone:
      options.hideFromClone === true || options.hidefromclone === true,
    // account for UMD too where modifiers will be lowercased to work
    keepToClone: options.keepToClone === true || options.keeptoclone === true,

    tween: options.tween === true,
    tweenFromOpacity: getOpacity(options.tweenFromOpacity, 0.6),
    tweenToOpacity: getOpacity(options.tweenToOpacity, 0.5)
  }
}

function getElement(element) {
  const type = typeof element

  return type === 'function'
    ? element()
    : type === 'string'
      ? document.querySelector(element)
      : element
}

function isValidElement(element) {
  return (
    element && element.ownerDocument === document && element.parentNode !== null
  )
}

export default function morph(_options) {
  let cancel = defaultCancel
  let cancelStatus = false
  let endElementTo = true

  const elements = normalizeElements(_options)
  const options = normalizeOptions(_options)

  const elFrom = getElement(elements.from)
  if (!isValidElement(elFrom)) {
    // we return a cancel function that return false, meaning the cancel function failed
    return cancel
  }
  // we clean other morphs running on this element
  if (typeof elFrom.qMorphCancel === 'function') elFrom.qMorphCancel()

  let animationFromClone = void 0
  let animationFromTween = void 0
  let animationToClone = void 0
  let animationTo = void 0

  const elFromParent = elFrom.parentNode
  const elFromNext = elFrom.nextElementSibling

  // we get the dimensions and characteristics
  // of the parent of the initial element before changes
  const elFromPosition = getAbsolutePosition(elFrom, options.resize)
  const { width: elFromParentWidthBefore, height: elFromParentHeightBefore } =
    getAbsoluteSize(elFromParent)
  const {
    borderWidth: elFromBorderWidth,
    borderStyle: elFromBorderStyle,
    borderColor: elFromBorderColor,
    borderRadius: elFromBorderRadius,
    backgroundColor: elFromBackground,
    transform: elFromTransform,
    position: elFromPositioningType,
    cssText: elFromCssText
  } = getComputedStyle(elFrom, [
    'borderWidth',
    'borderStyle',
    'borderColor',
    'borderRadius',
    'backgroundColor',
    'transform',
    'position',
    'cssText'
  ])
  const elFromClassSaved = elFrom.classList.toString()
  const elFromStyleSaved = elFrom.style.cssText

  // we make a clone of the initial element and
  // use it to display until the final element is ready
  // and to change the occupied space during animation
  const elFromClone = elFrom.cloneNode(true)
  const elFromTween = options.tween === true ? elFrom.cloneNode(true) : void 0

  if (elFromTween !== void 0) {
    elFromTween.className = elFromTween.classList
      .toString()
      .split(' ')
      .filter(c => !c.startsWith('bg-'))
      .join(' ')
  }

  // if the initial element is not going to be removed do not show the placeholder
  if (options.hideFromClone === true) {
    elFromClone.classList.add('q-morph--internal')
  }

  // prevent interaction with placeholder
  elFromClone.setAttribute('aria-hidden', 'true')
  elFromClone.style.transition = 'none'
  elFromClone.style.animation = 'none'
  elFromClone.style.pointerEvents = 'none'

  // oxlint-disable-next-line unicorn/prefer-modern-dom-apis
  elFromParent.insertBefore(elFromClone, elFromNext)

  // we mark the element with its cleanup function
  elFrom.qMorphCancel = () => {
    cancelStatus = true

    // we clean the clone of the initial element
    elFromClone.remove()
    elFromTween?.remove()

    if (options.hideFromClone === true) {
      elFromClone.classList.remove('q-morph--internal')
    }

    // we remove the cleanup function from the element
    elFrom.qMorphCancel = void 0
  }

  // will be called after Vue catches up with the changes done by _options.onToggle() function
  const calculateFinalState = () => {
    const elTo = getElement(elements.to)
    if (cancelStatus === true || !isValidElement(elTo)) {
      if (typeof elFrom.qMorphCancel === 'function') elFrom.qMorphCancel()
      return
    }
    // we clean other morphs running on this element
    if (elFrom !== elTo && typeof elTo.qMorphCancel === 'function') {
      elTo.qMorphCancel()
    }

    // we hide the final element and the clone of the initial element
    // we don't hide the final element if we want both it and the animated one visible
    if (options.keepToClone !== true) elTo.classList.add('q-morph--internal')
    elFromClone.classList.add('q-morph--internal')

    // we get the dimensions of the parent of the initial element after changes
    // the difference is how much we should animate the clone
    const { width: elFromParentWidthAfter, height: elFromParentHeightAfter } =
      getAbsoluteSize(elFromParent)

    // we get the dimensions of the parent of the final element before changes
    const { width: elToParentWidthBefore, height: elToParentHeightBefore } =
      getAbsoluteSize(elTo.parentNode)

    // then we show the clone of the initial element if we don't want it hidden
    if (options.hideFromClone !== true) {
      elFromClone.classList.remove('q-morph--internal')
    }

    // we mark the element with its cleanup function
    elTo.qMorphCancel = () => {
      cancelStatus = true

      // we clean the clone of the initial element
      elFromClone.remove()
      elFromTween?.remove()

      if (options.hideFromClone === true) {
        elFromClone.classList.remove('q-morph--internal')
      }

      // we show the final element
      if (options.keepToClone !== true) {
        elTo.classList.remove('q-morph--internal')
      }

      // we remove the cleanup function from the elements
      elFrom.qMorphCancel = void 0
      elTo.qMorphCancel = void 0
    }

    // will be called after waitFor (give time to render the final element)
    const animate = () => {
      if (cancelStatus === true) {
        if (typeof elTo.qMorphCancel === 'function') elTo.qMorphCancel()
        return
      }

      // now the animation starts, so we only need the clone
      // of the initial element as a spacer
      // we also hide it to calculate the dimensions of the
      // parent of the final element after the changes
      if (options.hideFromClone !== true) {
        elFromClone.classList.add('q-morph--internal')
        elFromClone.innerHTML = ''
        elFromClone.style.left = 0
        elFromClone.style.right = 'unset'
        elFromClone.style.top = 0
        elFromClone.style.bottom = 'unset'
        elFromClone.style.transform = 'none'
      }

      // we show the final element
      if (options.keepToClone !== true) {
        elTo.classList.remove('q-morph--internal')
      }

      // we get the dimensions of the parent of the final element after changes
      // the difference is how much we should animate the clone
      const elToParent = elTo.parentNode
      const { width: elToParentWidthAfter, height: elToParentHeightAfter } =
        getAbsoluteSize(elToParent)

      const elToClone = elTo.cloneNode(options.keepToClone)
      elToClone.setAttribute('aria-hidden', 'true')
      if (options.keepToClone !== true) {
        elToClone.style.left = 0
        elToClone.style.right = 'unset'
        elToClone.style.top = 0
        elToClone.style.bottom = 'unset'
        elToClone.style.transform = 'none'
        elToClone.style.pointerEvents = 'none'
      }
      elToClone.classList.add('q-morph--internal')

      // if elFrom is the same as elTo the next element is elFromClone
      const elToNext =
        elTo === elFrom && elFromParent === elToParent
          ? elFromClone
          : elTo.nextElementSibling

      // oxlint-disable-next-line unicorn/prefer-modern-dom-apis
      elToParent.insertBefore(elToClone, elToNext)

      const {
        borderWidth: elToBorderWidth,
        borderStyle: elToBorderStyle,
        borderColor: elToBorderColor,
        borderRadius: elToBorderRadius,
        backgroundColor: elToBackground,
        transform: elToTransform,
        position: elToPositioningType,
        cssText: elToCssText
      } = getComputedStyle(elTo, [
        'borderWidth',
        'borderStyle',
        'borderColor',
        'borderRadius',
        'backgroundColor',
        'transform',
        'position',
        'cssText'
      ])
      const elToClassSaved = elTo.classList.toString()
      const elToStyleSaved = elTo.style.cssText

      // we set the computed styles on the element (to be able to remove classes)
      elTo.style.cssText = elToCssText
      elTo.style.transform = 'none'
      elTo.style.animation = 'none'
      elTo.style.transition = 'none'
      // we strip the background classes (background color can no longer be animated if !important is used)
      elTo.className = elToClassSaved
        .split(' ')
        .filter(c => !c.startsWith('bg-'))
        .join(' ')

      const elToPosition = getAbsolutePosition(elTo, options.resize)

      const deltaX = elFromPosition.left - elToPosition.left
      const deltaY = elFromPosition.top - elToPosition.top
      const scaleX =
        elFromPosition.width /
        (elToPosition.width > 0 ? elToPosition.width : 10)
      const scaleY =
        elFromPosition.height /
        (elToPosition.height > 0 ? elToPosition.height : 100)

      const elFromParentWidthDiff =
        elFromParentWidthBefore - elFromParentWidthAfter
      const elFromParentHeightDiff =
        elFromParentHeightBefore - elFromParentHeightAfter
      const elToParentWidthDiff = elToParentWidthAfter - elToParentWidthBefore
      const elToParentHeightDiff =
        elToParentHeightAfter - elToParentHeightBefore

      const elFromCloneWidth = Math.max(
        elFromPosition.widthM,
        elFromParentWidthDiff
      )
      const elFromCloneHeight = Math.max(
        elFromPosition.heightM,
        elFromParentHeightDiff
      )
      const elToCloneWidth = Math.max(elToPosition.widthM, elToParentWidthDiff)
      const elToCloneHeight = Math.max(
        elToPosition.heightM,
        elToParentHeightDiff
      )

      const elSharedSize =
        elFrom === elTo &&
        !['absolute', 'fixed'].includes(elToPositioningType) &&
        !['absolute', 'fixed'].includes(elFromPositioningType)

      // if the final element has fixed position or if a parent
      // has fixed position we need to animate it as fixed
      let elToNeedsFixedPosition = elToPositioningType === 'fixed'
      let parent = elToParent
      while (!elToNeedsFixedPosition && parent !== document) {
        elToNeedsFixedPosition =
          window.getComputedStyle(parent).position === 'fixed'
        parent = parent.parentNode
      }

      // we show the spacer for the initial element
      if (options.hideFromClone !== true) {
        elFromClone.style.display = 'block'
        elFromClone.style.flex = '0 0 auto'
        elFromClone.style.opacity = 0
        elFromClone.style.minWidth = 'unset'
        elFromClone.style.maxWidth = 'unset'
        elFromClone.style.minHeight = 'unset'
        elFromClone.style.maxHeight = 'unset'
        elFromClone.classList.remove('q-morph--internal')
      }

      // we show the spacer for the final element
      if (options.keepToClone !== true) {
        elToClone.style.display = 'block'
        elToClone.style.flex = '0 0 auto'
        elToClone.style.opacity = 0
        elToClone.style.minWidth = 'unset'
        elToClone.style.maxWidth = 'unset'
        elToClone.style.minHeight = 'unset'
        elToClone.style.maxHeight = 'unset'
      }
      elToClone.classList.remove('q-morph--internal')

      // we apply classes specified by user
      if (typeof options.classes === 'string') {
        elTo.className += ' ' + options.classes
      }

      // we apply styles specified by user
      if (typeof options.style === 'string') {
        elTo.style.cssText += ' ' + options.style
      } else if (isObject(options.style)) {
        for (const prop in options.style) {
          elTo.style[prop] = options.style[prop]
        }
      }

      const elFromZIndex = getMaxZIndex(elFromClone)
      const elToZIndex = getMaxZIndex(elTo)

      // we position the morphing element
      // if we use fixed position for the final element we need to adjust for scroll
      const documentScroll = elToNeedsFixedPosition
        ? document.documentElement
        : { scrollLeft: 0, scrollTop: 0 }
      elTo.style.position = elToNeedsFixedPosition ? 'fixed' : 'absolute'
      elTo.style.left = `${elToPosition.left - documentScroll.scrollLeft}px`
      elTo.style.right = 'unset'
      elTo.style.top = `${elToPosition.top - documentScroll.scrollTop}px`
      elTo.style.margin = 0

      if (options.resize === true) {
        elTo.style.minWidth = 'unset'
        elTo.style.maxWidth = 'unset'
        elTo.style.minHeight = 'unset'
        elTo.style.maxHeight = 'unset'
        elTo.style.overflow = 'hidden'
        elTo.style.overflowX = 'hidden'
        elTo.style.overflowY = 'hidden'
      }

      document.body.append(elTo)

      if (elFromTween !== void 0) {
        elFromTween.style.cssText = elFromCssText
        elFromTween.style.transform = 'none'
        elFromTween.style.animation = 'none'
        elFromTween.style.transition = 'none'

        elFromTween.style.position = elTo.style.position
        elFromTween.style.left = `${elFromPosition.left - documentScroll.scrollLeft}px`
        elFromTween.style.right = 'unset'
        elFromTween.style.top = `${elFromPosition.top - documentScroll.scrollTop}px`
        elFromTween.style.margin = 0
        elFromTween.style.pointerEvents = 'none'

        if (options.resize === true) {
          elFromTween.style.minWidth = 'unset'
          elFromTween.style.maxWidth = 'unset'
          elFromTween.style.minHeight = 'unset'
          elFromTween.style.maxHeight = 'unset'
          elFromTween.style.overflow = 'hidden'
          elFromTween.style.overflowX = 'hidden'
          elFromTween.style.overflowY = 'hidden'
        }

        document.body.append(elFromTween)
      }

      const commonCleanup = aborted => {
        // we put the element back in it's place
        // and restore the styles and classes
        if (elFrom === elTo && endElementTo !== true) {
          elTo.style.cssText = elFromStyleSaved
          elTo.className = elFromClassSaved
        } else {
          elTo.style.cssText = elToStyleSaved
          elTo.className = elToClassSaved
        }

        if (elToClone.parentNode === elToParent) {
          elToClone.before(elTo)
        }

        // we clean the spacers
        elFromClone.remove()
        elToClone.remove()
        elFromTween?.remove()

        // cancel will be no longer available
        cancel = defaultCancel

        elFrom.qMorphCancel = void 0
        elTo.qMorphCancel = void 0

        // we are ready
        if (typeof options.onEnd === 'function') {
          options.onEnd(endElementTo === true ? 'to' : 'from', aborted === true)
        }
      }

      if (options.useCSS !== true && typeof elTo.animate === 'function') {
        const resizeFrom =
          options.resize === true
            ? {
                transform: `translate(${deltaX}px, ${deltaY}px)`,
                width: `${elFromCloneWidth}px`,
                height: `${elFromCloneHeight}px`
              }
            : {
                transform: `translate(${deltaX}px, ${deltaY}px) scale(${scaleX}, ${scaleY})`
              }
        const resizeTo =
          options.resize === true
            ? {
                width: `${elToCloneWidth}px`,
                height: `${elToCloneHeight}px`
              }
            : {}
        const resizeFromTween =
          options.resize === true
            ? {
                width: `${elFromCloneWidth}px`,
                height: `${elFromCloneHeight}px`
              }
            : {}
        const resizeToTween =
          options.resize === true
            ? {
                transform: `translate(${-1 * deltaX}px, ${-1 * deltaY}px)`,
                width: `${elToCloneWidth}px`,
                height: `${elToCloneHeight}px`
              }
            : {
                transform: `translate(${-1 * deltaX}px, ${-1 * deltaY}px) scale(${1 / scaleX}, ${1 / scaleY})`
              }
        const tweenFrom =
          elFromTween !== void 0
            ? { opacity: options.tweenToOpacity }
            : { backgroundColor: elFromBackground }
        const tweenTo =
          elFromTween !== void 0
            ? { opacity: 1 }
            : { backgroundColor: elToBackground }
        animationTo = elTo.animate(
          [
            {
              margin: 0,
              borderWidth: elFromBorderWidth,
              borderStyle: elFromBorderStyle,
              borderColor: elFromBorderColor,
              borderRadius: elFromBorderRadius,
              zIndex: elFromZIndex,
              transformOrigin: '0 0',
              ...resizeFrom,
              ...tweenFrom
            },
            {
              margin: 0,
              borderWidth: elToBorderWidth,
              borderStyle: elToBorderStyle,
              borderColor: elToBorderColor,
              borderRadius: elToBorderRadius,
              zIndex: elToZIndex,
              transformOrigin: '0 0',
              transform: elToTransform,
              ...resizeTo,
              ...tweenTo
            }
          ],
          {
            duration: options.duration,
            easing: options.easing,
            fill: options.fill,
            delay: options.delay
          }
        )

        animationFromTween =
          elFromTween === void 0
            ? void 0
            : elFromTween.animate(
                [
                  {
                    opacity: options.tweenFromOpacity,
                    margin: 0,
                    borderWidth: elFromBorderWidth,
                    borderStyle: elFromBorderStyle,
                    borderColor: elFromBorderColor,
                    borderRadius: elFromBorderRadius,
                    zIndex: elFromZIndex,
                    transformOrigin: '0 0',
                    transform: elFromTransform,
                    ...resizeFromTween
                  },
                  {
                    opacity: 0,
                    margin: 0,
                    borderWidth: elToBorderWidth,
                    borderStyle: elToBorderStyle,
                    borderColor: elToBorderColor,
                    borderRadius: elToBorderRadius,
                    zIndex: elToZIndex,
                    transformOrigin: '0 0',
                    ...resizeToTween
                  }
                ],
                {
                  duration: options.duration,
                  easing: options.easing,
                  fill: options.fill,
                  delay: options.delay
                }
              )

        animationFromClone =
          options.hideFromClone === true || elSharedSize === true
            ? void 0
            : elFromClone.animate(
                [
                  {
                    margin: `${elFromParentHeightDiff < 0 ? elFromParentHeightDiff / 2 : 0}px ${elFromParentWidthDiff < 0 ? elFromParentWidthDiff / 2 : 0}px`,
                    width: `${elFromCloneWidth + elFromPosition.marginH}px`,
                    height: `${elFromCloneHeight + elFromPosition.marginV}px`
                  },
                  {
                    margin: 0,
                    width: 0,
                    height: 0
                  }
                ],
                {
                  duration: options.duration,
                  easing: options.easing,
                  fill: options.fill,
                  delay: options.delay
                }
              )

        animationToClone =
          options.keepToClone === true
            ? void 0
            : elToClone.animate(
                [
                  elSharedSize === true
                    ? {
                        margin: `${elFromParentHeightDiff < 0 ? elFromParentHeightDiff / 2 : 0}px ${elFromParentWidthDiff < 0 ? elFromParentWidthDiff / 2 : 0}px`,
                        width: `${elFromCloneWidth + elFromPosition.marginH}px`,
                        height: `${elFromCloneHeight + elFromPosition.marginV}px`
                      }
                    : {
                        margin: 0,
                        width: 0,
                        height: 0
                      },
                  {
                    margin: `${elToParentHeightDiff < 0 ? elToParentHeightDiff / 2 : 0}px ${elToParentWidthDiff < 0 ? elToParentWidthDiff / 2 : 0}px`,
                    width: `${elToCloneWidth + elToPosition.marginH}px`,
                    height: `${elToCloneHeight + elToPosition.marginV}px`
                  }
                ],
                {
                  duration: options.duration,
                  easing: options.easing,
                  fill: options.fill,
                  delay: options.delay
                }
              )

        const cleanup = abort => {
          animationFromClone?.cancel()
          animationFromTween?.cancel()
          animationToClone?.cancel()
          animationTo.cancel()

          animationTo.removeEventListener('finish', cleanup)
          animationTo.removeEventListener('cancel', cleanup)

          commonCleanup(abort)

          // we clean the animations
          animationFromClone = void 0
          animationFromTween = void 0
          animationToClone = void 0
          animationTo = void 0
        }

        elFrom.qMorphCancel = () => {
          elFrom.qMorphCancel = void 0
          cancelStatus = true
          cleanup()
        }
        elTo.qMorphCancel = () => {
          elTo.qMorphCancel = void 0
          cancelStatus = true
          cleanup()
        }

        animationTo.addEventListener('finish', cleanup)
        animationTo.addEventListener('cancel', cleanup)

        cancel = abort => {
          // we are not in a morph that we can cancel
          if (cancelStatus === true || animationTo === void 0) {
            return false
          }

          if (abort === true) {
            cleanup(true)
            return true
          }

          endElementTo = endElementTo !== true

          animationFromClone?.reverse()
          animationFromTween?.reverse()
          animationToClone?.reverse()
          animationTo.reverse()

          return true
        }
      } else {
        const qAnimId = `q-morph-anim-${++id}`
        const style = document.createElement('style')
        const resizeFrom =
          options.resize === true
            ? `
            transform: translate(${deltaX}px, ${deltaY}px);
            width: ${elFromCloneWidth}px;
            height: ${elFromCloneHeight}px;
          `
            : `transform: translate(${deltaX}px, ${deltaY}px) scale(${scaleX}, ${scaleY});`
        const resizeTo =
          options.resize === true
            ? `
            width: ${elToCloneWidth}px;
            height: ${elToCloneHeight}px;
          `
            : ''
        const resizeFromTween =
          options.resize === true
            ? `
            width: ${elFromCloneWidth}px;
            height: ${elFromCloneHeight}px;
          `
            : ''
        const resizeToTween =
          options.resize === true
            ? `
            transform: translate(${-1 * deltaX}px, ${-1 * deltaY}px);
            width: ${elToCloneWidth}px;
            height: ${elToCloneHeight}px;
          `
            : `transform: translate(${-1 * deltaX}px, ${-1 * deltaY}px) scale(${1 / scaleX}, ${1 / scaleY});`
        const tweenFrom =
          elFromTween !== void 0
            ? `opacity: ${options.tweenToOpacity};`
            : `background-color: ${elFromBackground};`
        const tweenTo =
          elFromTween !== void 0
            ? 'opacity: 1;'
            : `background-color: ${elToBackground};`
        const keyframesFromTween =
          elFromTween === void 0
            ? ''
            : `
            @keyframes ${qAnimId}-from-tween {
              0% {
                opacity: ${options.tweenFromOpacity};
                margin: 0;
                border-width: ${elFromBorderWidth};
                border-style: ${elFromBorderStyle};
                border-color: ${elFromBorderColor};
                border-radius: ${elFromBorderRadius};
                z-index: ${elFromZIndex};
                transform-origin: 0 0;
                transform: ${elFromTransform};
                ${resizeFromTween}
              }

              100% {
                opacity: 0;
                margin: 0;
                border-width: ${elToBorderWidth};
                border-style: ${elToBorderStyle};
                border-color: ${elToBorderColor};
                border-radius: ${elToBorderRadius};
                z-index: ${elToZIndex};
                transform-origin: 0 0;
                ${resizeToTween}
              }
            }
          `
        const hasFromKeyframes =
          options.hideFromClone !== true && elSharedSize !== true
        const hasToKeyframes = options.keepToClone !== true

        const keyframesFrom = !hasFromKeyframes
          ? ''
          : `
            @keyframes ${qAnimId}-from {
              0% {
                margin: ${elFromParentHeightDiff < 0 ? elFromParentHeightDiff / 2 : 0}px ${elFromParentWidthDiff < 0 ? elFromParentWidthDiff / 2 : 0}px;
                width: ${elFromCloneWidth + elFromPosition.marginH}px;
                height: ${elFromCloneHeight + elFromPosition.marginV}px;
              }

              100% {
                margin: 0;
                width: 0;
                height: 0;
              }
            }
          `
        const keyframeToStart =
          elSharedSize === true
            ? `
            margin: ${elFromParentHeightDiff < 0 ? elFromParentHeightDiff / 2 : 0}px ${elFromParentWidthDiff < 0 ? elFromParentWidthDiff / 2 : 0}px;
            width: ${elFromCloneWidth + elFromPosition.marginH}px;
            height: ${elFromCloneHeight + elFromPosition.marginV}px;
          `
            : `
            margin: 0;
            width: 0;
            height: 0;
          `
        const keyframesTo = !hasToKeyframes
          ? ''
          : `
            @keyframes ${qAnimId}-to {
              0% {
                ${keyframeToStart}
              }

              100% {
                margin: ${elToParentHeightDiff < 0 ? elToParentHeightDiff / 2 : 0}px ${elToParentWidthDiff < 0 ? elToParentWidthDiff / 2 : 0}px;
                width: ${elToCloneWidth + elToPosition.marginH}px;
                height: ${elToCloneHeight + elToPosition.marginV}px;
              }
            }
          `
        style.innerHTML = `
          @keyframes ${qAnimId} {
            0% {
              margin: 0;
              border-width: ${elFromBorderWidth};
              border-style: ${elFromBorderStyle};
              border-color: ${elFromBorderColor};
              border-radius: ${elFromBorderRadius};
              background-color: ${elFromBackground};
              z-index: ${elFromZIndex};
              transform-origin: 0 0;
              ${resizeFrom}
              ${tweenFrom}
            }

            100% {
              margin: 0;
              border-width: ${elToBorderWidth};
              border-style: ${elToBorderStyle};
              border-color: ${elToBorderColor};
              border-radius: ${elToBorderRadius};
              background-color: ${elToBackground};
              z-index: ${elToZIndex};
              transform-origin: 0 0;
              transform: ${elToTransform};
              ${resizeTo}
              ${tweenTo}
            }
          }

          ${keyframesFrom}

          ${keyframesFromTween}

          ${keyframesTo}
        `
        document.head.append(style)

        let animationDirection = 'normal'

        // only point an element at keyframes that were actually generated
        const getAnimation = suffix =>
          `${options.duration}ms ${options.easing} ${options.delay}ms ${animationDirection} ${options.fill} ${qAnimId}${suffix}`

        elFromClone.style.animation = hasFromKeyframes
          ? getAnimation('-from')
          : 'none'
        if (elFromTween !== void 0) {
          elFromTween.style.animation = getAnimation('-from-tween')
        }
        elToClone.style.animation = hasToKeyframes
          ? getAnimation('-to')
          : 'none'
        elTo.style.animation = getAnimation('')

        const cleanup = evt => {
          if (evt === Object(evt) && evt.animationName !== qAnimId) return

          elTo.removeEventListener('animationend', cleanup)
          elTo.removeEventListener('animationcancel', cleanup)

          commonCleanup()

          // we clean the animations
          style.remove()
        }

        elFrom.qMorphCancel = () => {
          elFrom.qMorphCancel = void 0
          cancelStatus = true
          cleanup()
        }
        elTo.qMorphCancel = () => {
          elTo.qMorphCancel = void 0
          cancelStatus = true
          cleanup()
        }

        elTo.addEventListener('animationend', cleanup)
        elTo.addEventListener('animationcancel', cleanup)

        cancel = abort => {
          // we are not in a morph that we can cancel
          if (cancelStatus === true || !elTo || !elFromClone || !elToClone) {
            return false
          }

          if (abort === true) {
            cleanup()

            return true
          }

          endElementTo = endElementTo !== true

          animationDirection =
            animationDirection === 'normal' ? 'reverse' : 'normal'

          elFromClone.style.animationDirection = animationDirection
          if (elFromTween !== void 0) {
            elFromTween.style.animationDirection = animationDirection
          }
          elToClone.style.animationDirection = animationDirection
          elTo.style.animationDirection = animationDirection

          return true
        }
      }
    }

    if (
      options.waitFor > 0 ||
      options.waitFor === 'transitionend' ||
      (options.waitFor === Object(options.waitFor) &&
        typeof options.waitFor.then === 'function')
    ) {
      const delayPromise =
        options.waitFor > 0
          ? new Promise(resolve => {
              setTimeout(resolve, options.waitFor)
            })
          : options.waitFor === 'transitionend'
            ? new Promise(resolve => {
                const endFn = () => {
                  if (timer !== null) {
                    clearTimeout(timer)
                    timer = null
                  }

                  if (elTo) {
                    elTo.removeEventListener('transitionend', endFn)
                    elTo.removeEventListener('transitioncancel', endFn)
                  }

                  // oxlint-disable-next-line promise/no-multiple-resolved
                  resolve?.()
                  resolve = null
                }

                let timer = setTimeout(endFn, 400)

                elTo.addEventListener('transitionend', endFn)
                elTo.addEventListener('transitioncancel', endFn)
              })
            : options.waitFor

      delayPromise.then(animate).catch(() => {
        if (typeof elTo.qMorphCancel === 'function') elTo.qMorphCancel()
      })
    } else {
      animate()
    }
  }

  if (typeof _options.onToggle === 'function') _options.onToggle()
  requestAnimationFrame(calculateFinalState)

  // we return the cancel function
  // returns:
  //   false if the cancel cannot be performed (the morph ended already or has not started)
  //   true else
  return abort => cancel(abort)
}
