import {
  Comment,
  Fragment,
  Static,
  Text,
  type VNode,
  type VNodeHook,
  createTextVNode,
  createVNode,
  invokeVNodeHook,
  normalizeVNode,
} from './vnode'
import { flushPostFlushCbs } from './scheduler'
import type { ComponentInternalInstance } from './component'
import { invokeDirectiveHook } from './directives'
import { warn } from './warning'
import {
  PatchFlags,
  ShapeFlags,
  def,
  getEscapedCssVarName,
  includeBooleanAttr,
  isBooleanAttr,
  isKnownHtmlAttr,
  isKnownSvgAttr,
  isOn,
  isRenderableAttrValue,
  isReservedProp,
  isString,
  normalizeClass,
  normalizeStyle,
  stringifyStyle,
} from '@vue/shared'
import { type RendererInternals, needTransition } from './renderer'
import { setRef } from './rendererTemplateRef'
import {
  type SuspenseBoundary,
  type SuspenseImpl,
  queueEffectWithSuspense,
} from './components/Suspense'
import type { TeleportImpl, TeleportVNode } from './components/Teleport'
import { isAsyncWrapper } from './apiAsyncComponent'
import { isReactive } from '@vue/reactivity'

export type RootHydrateFunction = (
  vnode: VNode<Node, Element>,
  container: (Element | ShadowRoot) & { _vnode?: VNode },
) => void

export enum DOMNodeTypes {
  ELEMENT = 1,
  TEXT = 3,
  COMMENT = 8,
}

let hasLoggedMismatchError = false
const logMismatchError = () => {
  if (__TEST__ || hasLoggedMismatchError) {
    return
  }
  // this error should show up in production
  console.error('Hydration completed but contains mismatches.')
  hasLoggedMismatchError = true
}

const isSVGContainer = (container: Element) =>
  container.namespaceURI!.includes('svg') &&
  container.tagName !== 'foreignObject'

const isMathMLContainer = (container: Element) =>
  container.namespaceURI!.includes('MathML')

const getContainerType = (
  container: Element | ShadowRoot,
): 'svg' | 'mathml' | undefined => {
  if (container.nodeType !== DOMNodeTypes.ELEMENT) return undefined
  if (isSVGContainer(container as Element)) return 'svg'
  if (isMathMLContainer(container as Element)) return 'mathml'
  return undefined
}

export const isComment = (node: Node): node is Comment =>
  node.nodeType === DOMNodeTypes.COMMENT

// Note: hydration is DOM-specific
// But we have to place it in core due to tight coupling with core - splitting
// it out creates a ton of unnecessary complexity.
// Hydration also depends on some renderer internal logic which needs to be
// passed in via arguments.
export function createHydrationFunctions(
  rendererInternals: RendererInternals<Node, Element>,
): [
  RootHydrateFunction,
  (
    node: Node,
    vnode: VNode,
    parentComponent: ComponentInternalInstance | null,
    parentSuspense: SuspenseBoundary | null,
    slotScopeIds: string[] | null,
    optimized?: boolean,
  ) => Node | null,
] {
  const {
    mt: mountComponent,
    p: patch,
    o: {
      patchProp,
      createText,
      nextSibling,
      parentNode,
      remove,
      insert,
      createComment,
    },
  } = rendererInternals

  const hydrate: RootHydrateFunction = (vnode, container) => {
    if (!container.hasChildNodes()) {
      ;(__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) &&
        warn(
          `Attempting to hydrate existing markup but container is empty. ` +
            `Performing full mount instead.`,
        )
      patch(null, vnode, container)
      flushPostFlushCbs()
      container._vnode = vnode
      return
    }

    hydrateNode(container.firstChild!, vnode, null, null, null)
    flushPostFlushCbs()
    container._vnode = vnode
  }

  const hydrateNode = (
    node: Node,
    vnode: VNode,
    parentComponent: ComponentInternalInstance | null,
    parentSuspense: SuspenseBoundary | null,
    slotScopeIds: string[] | null,
    optimized = false,
  ): Node | null => {
    optimized = optimized || !!vnode.dynamicChildren
    const isFragmentStart = isComment(node) && node.data === '['
    const onMismatch = () =>
      handleMismatch(
        node,
        vnode,
        parentComponent,
        parentSuspense,
        slotScopeIds,
        isFragmentStart,
      )

    const { type, ref, shapeFlag, patchFlag } = vnode
    let domType = node.nodeType
    vnode.el = node

    if (__DEV__ || __FEATURE_PROD_DEVTOOLS__) {
      def(node, '__vnode', vnode, true)
      def(node, '__vueParentComponent', parentComponent, true)
    }

    if (patchFlag === PatchFlags.BAIL) {
      optimized = false
      vnode.dynamicChildren = null
    }

    let nextNode: Node | null = null
    switch (type) {
      case Text:
        if (domType !== DOMNodeTypes.TEXT) {
          // #5728 empty text node inside a slot can cause hydration failure
          // because the server rendered HTML won't contain a text node
          if (vnode.children === '') {
            insert((vnode.el = createText('')), parentNode(node)!, node)
            nextNode = node
          } else {
            nextNode = onMismatch()
          }
        } else {
          if ((node as Text).data !== vnode.children) {
            ;(__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) &&
              warn(
                `Hydration text mismatch in`,
                node.parentNode,
                `\n  - rendered on server: ${JSON.stringify(
                  (node as Text).data,
                )}` +
                  `\n  - expected on client: ${JSON.stringify(vnode.children)}`,
              )
            logMismatchError()
            ;(node as Text).data = vnode.children as string
          }
          nextNode = nextSibling(node)
        }
        break
      case Comment:
        if (isTemplateNode(node)) {
          nextNode = nextSibling(node)
          // wrapped <transition appear>
          // replace <template> node with inner child
          replaceNode(
            (vnode.el = node.content.firstChild!),
            node,
            parentComponent,
          )
        } else if (domType !== DOMNodeTypes.COMMENT || isFragmentStart) {
          nextNode = onMismatch()
        } else {
          nextNode = nextSibling(node)
        }
        break
      case Static:
        if (isFragmentStart) {
          // entire template is static but SSRed as a fragment
          node = nextSibling(node)!
          domType = node.nodeType
        }
        if (domType === DOMNodeTypes.ELEMENT || domType === DOMNodeTypes.TEXT) {
          // determine anchor, adopt content
          nextNode = node
          // if the static vnode has its content stripped during build,
          // adopt it from the server-rendered HTML.
          const needToAdoptContent = !(vnode.children as string).length
          for (let i = 0; i < vnode.staticCount!; i++) {
            if (needToAdoptContent)
              vnode.children +=
                nextNode.nodeType === DOMNodeTypes.ELEMENT
                  ? (nextNode as Element).outerHTML
                  : (nextNode as Text).data
            if (i === vnode.staticCount! - 1) {
              vnode.anchor = nextNode
            }
            nextNode = nextSibling(nextNode)!
          }
          return isFragmentStart ? nextSibling(nextNode) : nextNode
        } else {
          onMismatch()
        }
        break
      case Fragment:
        if (!isFragmentStart) {
          nextNode = onMismatch()
        } else {
          nextNode = hydrateFragment(
            node as Comment,
            vnode,
            parentComponent,
            parentSuspense,
            slotScopeIds,
            optimized,
          )
        }
        break
      default:
        if (shapeFlag & ShapeFlags.ELEMENT) {
          if (
            (domType !== DOMNodeTypes.ELEMENT ||
              (vnode.type as string).toLowerCase() !==
                (node as Element).tagName.toLowerCase()) &&
            !isTemplateNode(node)
          ) {
            nextNode = onMismatch()
          } else {
            nextNode = hydrateElement(
              node as Element,
              vnode,
              parentComponent,
              parentSuspense,
              slotScopeIds,
              optimized,
            )
          }
        } else if (shapeFlag & ShapeFlags.COMPONENT) {
          // when setting up the render effect, if the initial vnode already
          // has .el set, the component will perform hydration instead of mount
          // on its sub-tree.
          vnode.slotScopeIds = slotScopeIds
          const container = parentNode(node)!

          // Locate the next node.
          if (isFragmentStart) {
            // If it's a fragment: since components may be async, we cannot rely
            // on component's rendered output to determine the end of the
            // fragment. Instead, we do a lookahead to find the end anchor node.
            nextNode = locateClosingAnchor(node)
          } else if (isComment(node) && node.data === 'teleport start') {
            // #4293 #6152
            // If a teleport is at component root, look ahead for teleport end.
            nextNode = locateClosingAnchor(node, node.data, 'teleport end')
          } else {
            nextNode = nextSibling(node)
          }

          mountComponent(
            vnode,
            container,
            null,
            parentComponent,
            parentSuspense,
            getContainerType(container),
            optimized,
          )

          // #3787
          // if component is async, it may get moved / unmounted before its
          // inner component is loaded, so we need to give it a placeholder
          // vnode that matches its adopted DOM.
          if (isAsyncWrapper(vnode)) {
            let subTree
            if (isFragmentStart) {
              subTree = createVNode(Fragment)
              subTree.anchor = nextNode
                ? nextNode.previousSibling
                : container.lastChild
            } else {
              subTree =
                node.nodeType === 3 ? createTextVNode('') : createVNode('div')
            }
            subTree.el = node
            vnode.component!.subTree = subTree
          }
        } else if (shapeFlag & ShapeFlags.TELEPORT) {
          if (domType !== DOMNodeTypes.COMMENT) {
            nextNode = onMismatch()
          } else {
            nextNode = (vnode.type as typeof TeleportImpl).hydrate(
              node,
              vnode as TeleportVNode,
              parentComponent,
              parentSuspense,
              slotScopeIds,
              optimized,
              rendererInternals,
              hydrateChildren,
            )
          }
        } else if (__FEATURE_SUSPENSE__ && shapeFlag & ShapeFlags.SUSPENSE) {
          nextNode = (vnode.type as typeof SuspenseImpl).hydrate(
            node,
            vnode,
            parentComponent,
            parentSuspense,
            getContainerType(parentNode(node)!),
            slotScopeIds,
            optimized,
            rendererInternals,
            hydrateNode,
          )
        } else if (__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) {
          warn('Invalid HostVNode type:', type, `(${typeof type})`)
        }
    }

    if (ref != null) {
      setRef(ref, null, parentSuspense, vnode)
    }

    return nextNode
  }

  const hydrateElement = (
    el: Element,
    vnode: VNode,
    parentComponent: ComponentInternalInstance | null,
    parentSuspense: SuspenseBoundary | null,
    slotScopeIds: string[] | null,
    optimized: boolean,
  ) => {
    optimized = optimized || !!vnode.dynamicChildren
    const { type, props, patchFlag, shapeFlag, dirs, transition } = vnode
    // #4006 for form elements with non-string v-model value bindings
    // e.g. <option :value="obj">, <input type="checkbox" :true-value="1">
    // #7476 <input indeterminate>
    const forcePatch = type === 'input' || type === 'option'
    // skip props & children if this is hoisted static nodes
    // #5405 in dev, always hydrate children for HMR
    if (__DEV__ || forcePatch || patchFlag !== PatchFlags.CACHED) {
      if (dirs) {
        invokeDirectiveHook(vnode, null, parentComponent, 'created')
      }

      // handle appear transition
      let needCallTransitionHooks = false
      if (isTemplateNode(el)) {
        needCallTransitionHooks =
          needTransition(parentSuspense, transition) &&
          parentComponent &&
          parentComponent.vnode.props &&
          parentComponent.vnode.props.appear

        const content = (el as HTMLTemplateElement).content
          .firstChild as Element

        if (needCallTransitionHooks) {
          transition!.beforeEnter(content)
        }

        // replace <template> node with inner children
        replaceNode(content, el, parentComponent)
        vnode.el = el = content
      }

      // children
      if (
        shapeFlag & ShapeFlags.ARRAY_CHILDREN &&
        // skip if element has innerHTML / textContent
        !(props && (props.innerHTML || props.textContent))
      ) {
        let next = hydrateChildren(
          el.firstChild,
          vnode,
          el,
          parentComponent,
          parentSuspense,
          slotScopeIds,
          optimized,
        )
        let hasWarned = false
        while (next) {
          if (!isMismatchAllowed(el, MismatchTypes.CHILDREN)) {
            if (
              (__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) &&
              !hasWarned
            ) {
              warn(
                `Hydration children mismatch on`,
                el,
                `\nServer rendered element contains more child nodes than client vdom.`,
              )
              hasWarned = true
            }
            logMismatchError()
          }

          // The SSRed DOM contains more nodes than it should. Remove them.
          const cur = next
          next = next.nextSibling
          remove(cur)
        }
      } else if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
        // #11873 the HTML parser will "eat" the first newline when parsing
        // <pre> and <textarea>, so if the client value starts with a newline,
        // we need to remove it before comparing
        let clientText = vnode.children as string
        if (
          clientText[0] === '\n' &&
          (el.tagName === 'PRE' || el.tagName === 'TEXTAREA')
        ) {
          clientText = clientText.slice(1)
        }
        if (el.textContent !== clientText) {
          if (!isMismatchAllowed(el, MismatchTypes.TEXT)) {
            ;(__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) &&
              warn(
                `Hydration text content mismatch on`,
                el,
                `\n  - rendered on server: ${el.textContent}` +
                  `\n  - expected on client: ${vnode.children as string}`,
              )
            logMismatchError()
          }

          el.textContent = vnode.children as string
        }
      }

      // props
      if (props) {
        if (
          __DEV__ ||
          __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__ ||
          forcePatch ||
          !optimized ||
          patchFlag & (PatchFlags.FULL_PROPS | PatchFlags.NEED_HYDRATION)
        ) {
          const isCustomElement = el.tagName.includes('-')
          for (const key in props) {
            // check hydration mismatch
            if (
              (__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) &&
              // #11189 skip if this node has directives that have created hooks
              // as it could have mutated the DOM in any possible way
              !(dirs && dirs.some(d => d.dir.created)) &&
              propHasMismatch(el, key, props[key], vnode, parentComponent)
            ) {
              logMismatchError()
            }
            if (
              (forcePatch &&
                (key.endsWith('value') || key === 'indeterminate')) ||
              (isOn(key) && !isReservedProp(key)) ||
              // force hydrate v-bind with .prop modifiers
              key[0] === '.' ||
              isCustomElement
            ) {
              patchProp(el, key, null, props[key], undefined, parentComponent)
            }
          }
        } else if (props.onClick) {
          // Fast path for click listeners (which is most often) to avoid
          // iterating through props.
          patchProp(
            el,
            'onClick',
            null,
            props.onClick,
            undefined,
            parentComponent,
          )
        } else if (patchFlag & PatchFlags.STYLE && isReactive(props.style)) {
          // #11372: object style values are iterated during patch instead of
          // render/normalization phase, but style patch is skipped during
          // hydration, so we need to force iterate the object to track deps
          for (const key in props.style) props.style[key]
        }
      }

      // vnode / directive hooks
      let vnodeHooks: VNodeHook | null | undefined
      if ((vnodeHooks = props && props.onVnodeBeforeMount)) {
        invokeVNodeHook(vnodeHooks, parentComponent, vnode)
      }
      if (dirs) {
        invokeDirectiveHook(vnode, null, parentComponent, 'beforeMount')
      }
      if (
        (vnodeHooks = props && props.onVnodeMounted) ||
        dirs ||
        needCallTransitionHooks
      ) {
        queueEffectWithSuspense(() => {
          vnodeHooks && invokeVNodeHook(vnodeHooks, parentComponent, vnode)
          needCallTransitionHooks && transition!.enter(el)
          dirs && invokeDirectiveHook(vnode, null, parentComponent, 'mounted')
        }, parentSuspense)
      }
    }

    return el.nextSibling
  }

  const hydrateChildren = (
    node: Node | null,
    parentVNode: VNode,
    container: Element,
    parentComponent: ComponentInternalInstance | null,
    parentSuspense: SuspenseBoundary | null,
    slotScopeIds: string[] | null,
    optimized: boolean,
  ): Node | null => {
    optimized = optimized || !!parentVNode.dynamicChildren
    const children = parentVNode.children as VNode[]
    const l = children.length
    let hasWarned = false
    for (let i = 0; i < l; i++) {
      const vnode = optimized
        ? children[i]
        : (children[i] = normalizeVNode(children[i]))
      const isText = vnode.type === Text
      if (node) {
        if (isText && !optimized) {
          // #7285 possible consecutive text vnodes from manual render fns or
          // JSX-compiled fns, but on the client the browser parses only 1 text
          // node.
          // look ahead for next possible text vnode
          if (i + 1 < l && normalizeVNode(children[i + 1]).type === Text) {
            // create an extra TextNode on the client for the next vnode to
            // adopt
            insert(
              createText(
                (node as Text).data.slice((vnode.children as string).length),
              ),
              container,
              nextSibling(node),
            )
            ;(node as Text).data = vnode.children as string
          }
        }
        node = hydrateNode(
          node,
          vnode,
          parentComponent,
          parentSuspense,
          slotScopeIds,
          optimized,
        )
      } else if (isText && !vnode.children) {
        // #7215 create a TextNode for empty text node
        // because server rendered HTML won't contain a text node
        insert((vnode.el = createText('')), container)
      } else {
        if (!isMismatchAllowed(container, MismatchTypes.CHILDREN)) {
          if (
            (__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) &&
            !hasWarned
          ) {
            warn(
              `Hydration children mismatch on`,
              container,
              `\nServer rendered element contains fewer child nodes than client vdom.`,
            )
            hasWarned = true
          }
          logMismatchError()
        }

        // the SSRed DOM didn't contain enough nodes. Mount the missing ones.
        patch(
          null,
          vnode,
          container,
          null,
          parentComponent,
          parentSuspense,
          getContainerType(container),
          slotScopeIds,
        )
      }
    }
    return node
  }

  const hydrateFragment = (
    node: Comment,
    vnode: VNode,
    parentComponent: ComponentInternalInstance | null,
    parentSuspense: SuspenseBoundary | null,
    slotScopeIds: string[] | null,
    optimized: boolean,
  ) => {
    const { slotScopeIds: fragmentSlotScopeIds } = vnode
    if (fragmentSlotScopeIds) {
      slotScopeIds = slotScopeIds
        ? slotScopeIds.concat(fragmentSlotScopeIds)
        : fragmentSlotScopeIds
    }

    const container = parentNode(node)!
    const next = hydrateChildren(
      nextSibling(node)!,
      vnode,
      container,
      parentComponent,
      parentSuspense,
      slotScopeIds,
      optimized,
    )
    if (next && isComment(next) && next.data === ']') {
      return nextSibling((vnode.anchor = next))
    } else {
      // fragment didn't hydrate successfully, since we didn't get a end anchor
      // back. This should have led to node/children mismatch warnings.
      logMismatchError()

      // since the anchor is missing, we need to create one and insert it
      insert((vnode.anchor = createComment(`]`)), container, next)
      return next
    }
  }

  const handleMismatch = (
    node: Node,
    vnode: VNode,
    parentComponent: ComponentInternalInstance | null,
    parentSuspense: SuspenseBoundary | null,
    slotScopeIds: string[] | null,
    isFragment: boolean,
  ): Node | null => {
    if (!isMismatchAllowed(node.parentElement!, MismatchTypes.CHILDREN)) {
      ;(__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) &&
        warn(
          `Hydration node mismatch:\n- rendered on server:`,
          node,
          node.nodeType === DOMNodeTypes.TEXT
            ? `(text)`
            : isComment(node) && node.data === '['
              ? `(start of fragment)`
              : ``,
          `\n- expected on client:`,
          vnode.type,
        )
      logMismatchError()
    }

    vnode.el = null

    if (isFragment) {
      // remove excessive fragment nodes
      const end = locateClosingAnchor(node)
      while (true) {
        const next = nextSibling(node)
        if (next && next !== end) {
          remove(next)
        } else {
          break
        }
      }
    }

    const next = nextSibling(node)
    const container = parentNode(node)!
    remove(node)

    patch(
      null,
      vnode,
      container,
      next,
      parentComponent,
      parentSuspense,
      getContainerType(container),
      slotScopeIds,
    )
    return next
  }

  // looks ahead for a start and closing comment node
  const locateClosingAnchor = (
    node: Node | null,
    open = '[',
    close = ']',
  ): Node | null => {
    let match = 0
    while (node) {
      node = nextSibling(node)
      if (node && isComment(node)) {
        if (node.data === open) match++
        if (node.data === close) {
          if (match === 0) {
            return nextSibling(node)
          } else {
            match--
          }
        }
      }
    }
    return node
  }

  const replaceNode = (
    newNode: Node,
    oldNode: Node,
    parentComponent: ComponentInternalInstance | null,
  ): void => {
    // replace node
    const parentNode = oldNode.parentNode
    if (parentNode) {
      parentNode.replaceChild(newNode, oldNode)
    }

    // update vnode
    let parent = parentComponent
    while (parent) {
      if (parent.vnode.el === oldNode) {
        parent.vnode.el = parent.subTree.el = newNode
      }
      parent = parent.parent
    }
  }

  const isTemplateNode = (node: Node): node is HTMLTemplateElement => {
    return (
      node.nodeType === DOMNodeTypes.ELEMENT &&
      (node as Element).tagName === 'TEMPLATE'
    )
  }

  return [hydrate, hydrateNode]
}

/**
 * Dev only
 */
function propHasMismatch(
  el: Element,
  key: string,
  clientValue: any,
  vnode: VNode,
  instance: ComponentInternalInstance | null,
): boolean {
  let mismatchType: MismatchTypes | undefined
  let mismatchKey: string | undefined
  let actual: string | boolean | null | undefined
  let expected: string | boolean | null | undefined
  if (key === 'class') {
    // classes might be in different order, but that doesn't affect cascade
    // so we just need to check if the class lists contain the same classes.
    actual = el.getAttribute('class')
    expected = normalizeClass(clientValue)
    if (!isSetEqual(toClassSet(actual || ''), toClassSet(expected))) {
      mismatchType = MismatchTypes.CLASS
      mismatchKey = `class`
    }
  } else if (key === 'style') {
    // style might be in different order, but that doesn't affect cascade
    actual = el.getAttribute('style') || ''
    expected = isString(clientValue)
      ? clientValue
      : stringifyStyle(normalizeStyle(clientValue))
    const actualMap = toStyleMap(actual)
    const expectedMap = toStyleMap(expected)
    // If `v-show=false`, `display: 'none'` should be added to expected
    if (vnode.dirs) {
      for (const { dir, value } of vnode.dirs) {
        // @ts-expect-error only vShow has this internal name
        if (dir.name === 'show' && !value) {
          expectedMap.set('display', 'none')
        }
      }
    }

    if (instance) {
      resolveCssVars(instance, vnode, expectedMap)
    }

    if (!isMapEqual(actualMap, expectedMap)) {
      mismatchType = MismatchTypes.STYLE
      mismatchKey = 'style'
    }
  } else if (
    (el instanceof SVGElement && isKnownSvgAttr(key)) ||
    (el instanceof HTMLElement && (isBooleanAttr(key) || isKnownHtmlAttr(key)))
  ) {
    if (isBooleanAttr(key)) {
      actual = el.hasAttribute(key)
      expected = includeBooleanAttr(clientValue)
    } else if (clientValue == null) {
      actual = el.hasAttribute(key)
      expected = false
    } else {
      if (el.hasAttribute(key)) {
        actual = el.getAttribute(key)
      } else if (key === 'value' && el.tagName === 'TEXTAREA') {
        // #10000 textarea.value can't be retrieved by `hasAttribute`
        actual = (el as HTMLTextAreaElement).value
      } else {
        actual = false
      }
      expected = isRenderableAttrValue(clientValue)
        ? String(clientValue)
        : false
    }
    if (actual !== expected) {
      mismatchType = MismatchTypes.ATTRIBUTE
      mismatchKey = key
    }
  }

  if (mismatchType != null && !isMismatchAllowed(el, mismatchType)) {
    const format = (v: any) =>
      v === false ? `(not rendered)` : `${mismatchKey}="${v}"`
    const preSegment = `Hydration ${MismatchTypeString[mismatchType]} mismatch on`
    const postSegment =
      `\n  - rendered on server: ${format(actual)}` +
      `\n  - expected on client: ${format(expected)}` +
      `\n  Note: this mismatch is check-only. The DOM will not be rectified ` +
      `in production due to performance overhead.` +
      `\n  You should fix the source of the mismatch.`
    if (__TEST__) {
      // during tests, log the full message in one single string for easier
      // debugging.
      warn(`${preSegment} ${el.tagName}${postSegment}`)
    } else {
      warn(preSegment, el, postSegment)
    }
    return true
  }
  return false
}

function toClassSet(str: string): Set<string> {
  return new Set(str.trim().split(/\s+/))
}

function isSetEqual(a: Set<string>, b: Set<string>): boolean {
  if (a.size !== b.size) {
    return false
  }
  for (const s of a) {
    if (!b.has(s)) {
      return false
    }
  }
  return true
}

function toStyleMap(str: string): Map<string, string> {
  const styleMap: Map<string, string> = new Map()
  for (const item of str.split(';')) {
    let [key, value] = item.split(':')
    key = key.trim()
    value = value && value.trim()
    if (key && value) {
      styleMap.set(key, value)
    }
  }
  return styleMap
}

function isMapEqual(a: Map<string, string>, b: Map<string, string>): boolean {
  if (a.size !== b.size) {
    return false
  }
  for (const [key, value] of a) {
    if (value !== b.get(key)) {
      return false
    }
  }
  return true
}

function resolveCssVars(
  instance: ComponentInternalInstance,
  vnode: VNode,
  expectedMap: Map<string, string>,
) {
  const root = instance.subTree
  if (
    instance.getCssVars &&
    (vnode === root ||
      (root &&
        root.type === Fragment &&
        (root.children as VNode[]).includes(vnode)))
  ) {
    const cssVars = instance.getCssVars()
    for (const key in cssVars) {
      expectedMap.set(
        `--${getEscapedCssVarName(key, false)}`,
        String(cssVars[key]),
      )
    }
  }
  if (vnode === root && instance.parent) {
    resolveCssVars(instance.parent, instance.vnode, expectedMap)
  }
}

const allowMismatchAttr = 'data-allow-mismatch'

enum MismatchTypes {
  TEXT = 0,
  CHILDREN = 1,
  CLASS = 2,
  STYLE = 3,
  ATTRIBUTE = 4,
}

const MismatchTypeString: Record<MismatchTypes, string> = {
  [MismatchTypes.TEXT]: 'text',
  [MismatchTypes.CHILDREN]: 'children',
  [MismatchTypes.CLASS]: 'class',
  [MismatchTypes.STYLE]: 'style',
  [MismatchTypes.ATTRIBUTE]: 'attribute',
} as const

function isMismatchAllowed(
  el: Element | null,
  allowedType: MismatchTypes,
): boolean {
  if (
    allowedType === MismatchTypes.TEXT ||
    allowedType === MismatchTypes.CHILDREN
  ) {
    while (el && !el.hasAttribute(allowMismatchAttr)) {
      el = el.parentElement
    }
  }
  const allowedAttr = el && el.getAttribute(allowMismatchAttr)
  if (allowedAttr == null) {
    return false
  } else if (allowedAttr === '') {
    return true
  } else {
    const list = allowedAttr.split(',')
    // text is a subset of children
    if (allowedType === MismatchTypes.TEXT && list.includes('children')) {
      return true
    }
    return allowedAttr.split(',').includes(MismatchTypeString[allowedType])
  }
}
