import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
import { ConfigProvider, Popover, Tag, theme } from 'antd'
import { CloseCircleFilled } from '@ant-design/icons'
import PropTypes from 'prop-types'
import { createRoot } from 'react-dom/client'
import classNames from 'classnames'
import useCSSVarCls from 'antd/es/config-provider/hooks/useCSSVarCls'
import useSize from 'antd/es/config-provider/hooks/useSize'
import { useCompactItemContext } from 'antd/es/space/Compact'
import useStyle, { useSharedStyle } from 'antd/es/input/style'
import { useThemeContext } from '../context/ThemeContext'
import SimplePropertyFilter from './SimplePropertyFilter'
import ScrollBox from './ScrollBox'

// Longer symbols first so ".." / "<>" / ">=" match before "." / "<" / ">".
// Wildcards (* ?) and @ stay as plain text — they are not operands.
const OPERANDS = [
  { symbol: '..', label: 'TO', color: 'cyan' },
  { symbol: '<>', label: '≠', color: 'volcano' },
  { symbol: '>=', label: '> OR =', color: 'orange' },
  { symbol: '<=', label: '< OR =', color: 'orange' },
  { symbol: '|', label: 'OR', color: 'purple' },
  { symbol: '&', label: 'AND', color: 'purple' },
  { symbol: '>', label: '>', color: 'orange' },
  { symbol: '<', label: '<', color: 'orange' },
  { symbol: '=', label: '=', color: 'blue' }
]

const OPERAND_BY_SYMBOL = Object.fromEntries(
  OPERANDS.map((operand) => [operand.symbol, operand])
)

const tokenize = (raw = '') => {
  const value = String(raw)
  const tokens = []
  let text = ''
  let i = 0

  while (i < value.length) {
    const match = OPERANDS.find((operand) =>
      value.startsWith(operand.symbol, i)
    )
    if (match) {
      if (text) {
        tokens.push({ type: 'text', value: text })
        text = ''
      }
      tokens.push({ type: 'operand', value: match.symbol })
      i += match.symbol.length
    } else {
      text += value[i]
      i += 1
    }
  }

  if (text) tokens.push({ type: 'text', value: text })
  return tokens
}

const normalize = (raw = '') =>
  tokenize(raw)
    .map((token) => token.value)
    .join('')

const nodeLength = (node) => {
  if (!node) return 0
  if (node.nodeType === Node.TEXT_NODE) return node.textContent?.length ?? 0
  const operand = node.getAttribute?.('data-operand')
  if (operand != null) return operand.length
  return Array.from(node.childNodes).reduce(
    (sum, child) => sum + nodeLength(child),
    0
  )
}

const serializeNode = (root) => {
  if (!root) return ''
  let result = ''
  for (const node of root.childNodes) {
    if (node.nodeType === Node.TEXT_NODE) {
      result += node.textContent ?? ''
    } else if (node.getAttribute?.('data-operand') != null) {
      result += node.getAttribute('data-operand')
    } else {
      result += serializeNode(node)
    }
  }
  return result
}

/** Character offset in the serialized filter string for a DOM point. */
const getOffsetAt = (root, targetNode, targetOffset, chipSnap = 'end') => {
  if (!root || !targetNode || !root.contains(targetNode)) {
    return serializeNode(root).length
  }

  let offset = 0

  const visit = (node) => {
    if (node === targetNode) {
      if (node.nodeType === Node.TEXT_NODE) {
        offset += targetOffset
      } else if (node.getAttribute?.('data-operand') != null) {
        offset += targetOffset <= 0 ? 0 : node.getAttribute('data-operand').length
      } else {
        for (let i = 0; i < targetOffset; i += 1) {
          offset += nodeLength(node.childNodes[i])
        }
      }
      return true
    }

    if (node.nodeType === Node.TEXT_NODE) {
      offset += node.textContent?.length ?? 0
      return false
    }

    if (node.getAttribute?.('data-operand') != null) {
      if (node.contains(targetNode)) {
        // Chips are atomic: snap range starts to the chip start, ends to the chip end.
        if (chipSnap !== 'start') {
          offset += node.getAttribute('data-operand').length
        }
        return true
      }
      offset += node.getAttribute('data-operand').length
      return false
    }

    for (const child of node.childNodes) {
      if (visit(child)) return true
    }
    return false
  }

  visit(root)
  return offset
}

/** Character offset of the selection focus (caret). */
const getCaretOffset = (root) => {
  const selection = window.getSelection()
  if (!selection?.rangeCount || !root.contains(selection.focusNode)) {
    return serializeNode(root).length
  }
  return getOffsetAt(root, selection.focusNode, selection.focusOffset, 'end')
}

/** Serialized filter text for the current selection (operand symbols, not labels). */
const getSelectedSerializedText = (root) => {
  const selection = window.getSelection()
  if (!selection?.rangeCount || selection.isCollapsed) return ''

  const range = selection.getRangeAt(0)
  if (
    !root.contains(range.startContainer) ||
    !root.contains(range.endContainer)
  ) {
    return null
  }

  const start = getOffsetAt(
    root,
    range.startContainer,
    range.startOffset,
    'start'
  )
  const end = getOffsetAt(root, range.endContainer, range.endOffset, 'end')
  return serializeNode(root).slice(Math.min(start, end), Math.max(start, end))
}

/** Keep the caret / selection focus visible inside a horizontally clipped editor. */
const scrollPointIntoView = (root, clientX) => {
  if (!root || clientX == null || Number.isNaN(clientX)) return
  const rect = root.getBoundingClientRect()
  const pad = 8
  if (clientX > rect.right - pad) {
    root.scrollLeft += clientX - (rect.right - pad)
  } else if (clientX < rect.left + pad) {
    root.scrollLeft -= rect.left + pad - clientX
  }
}

const scrollSelectionIntoView = (root) => {
  if (!root) return
  const selection = window.getSelection()
  if (!selection?.rangeCount || !root.contains(selection.focusNode)) return

  try {
    const range = document.createRange()
    range.setStart(selection.focusNode, selection.focusOffset)
    range.collapse(true)
    const rects = range.getClientRects()
    const rect = rects[rects.length - 1] ?? range.getBoundingClientRect()
    if (rect && (rect.width > 0 || rect.height > 0)) {
      scrollPointIntoView(root, rect.left)
      return
    }
  } catch {
    // Fall through to caret-at-end heuristic.
  }

  // Empty / collapsed-at-end selections often have a zero rect — scroll to end.
  if (getCaretOffset(root) >= serializeNode(root).length) {
    root.scrollLeft = root.scrollWidth
  }
}

const caretRangeFromPoint = (clientX, clientY) => {
  if (document.caretRangeFromPoint) {
    return document.caretRangeFromPoint(clientX, clientY)
  }
  if (document.caretPositionFromPoint) {
    const pos = document.caretPositionFromPoint(clientX, clientY)
    if (!pos) return null
    const range = document.createRange()
    range.setStart(pos.offsetNode, pos.offset)
    range.collapse(true)
    return range
  }
  return null
}

/** Extend the live selection to the DOM point under the pointer (used while edge-scrolling). */
const extendSelectionToPoint = (root, clientX, clientY) => {
  const selection = window.getSelection()
  if (!selection?.rangeCount || !root) return
  const caret = caretRangeFromPoint(clientX, clientY)
  if (!caret || !root.contains(caret.startContainer)) return
  try {
    selection.setBaseAndExtent(
      selection.anchorNode,
      selection.anchorOffset,
      caret.startContainer,
      caret.startOffset
    )
  } catch {
    // Ignore invalid selection boundaries (e.g. inside non-editable chips).
  }
}

const setCaretOffset = (root, targetOffset) => {
  if (!root) return
  const selection = window.getSelection()
  if (!selection) return

  let remaining = Math.max(0, targetOffset)

  const place = (node, offset) => {
    const range = document.createRange()
    range.setStart(node, offset)
    range.collapse(true)
    selection.removeAllRanges()
    selection.addRange(range)
    scrollSelectionIntoView(root)
  }

  for (let i = 0; i < root.childNodes.length; i += 1) {
    const child = root.childNodes[i]
    const length = nodeLength(child)

    if (child.nodeType === Node.TEXT_NODE) {
      if (remaining <= length) {
        place(child, remaining)
        return
      }
      remaining -= length
      continue
    }

    if (child.getAttribute?.('data-operand') != null) {
      if (remaining === 0) {
        place(root, i)
        return
      }
      if (remaining <= length) {
        place(root, i + 1)
        return
      }
      remaining -= length
    }
  }

  const range = document.createRange()
  range.selectNodeContents(root)
  range.collapse(false)
  selection.removeAllRanges()
  selection.addRange(range)
  scrollSelectionIntoView(root)
}

/** True when the DOM already mirrors tokenize(serialized). */
const domMatchesTokens = (root, raw) => {
  const tokens = tokenize(raw)
  const children = Array.from(root.childNodes).filter((node) => {
    if (node.nodeType === Node.TEXT_NODE) return (node.textContent ?? '') !== ''
    return node.getAttribute?.('data-operand') != null
  })

  // Empty editor may keep a single empty text node.
  if (tokens.length === 0) {
    return (
      root.childNodes.length === 0 ||
      (root.childNodes.length === 1 &&
        root.childNodes[0].nodeType === Node.TEXT_NODE &&
        (root.childNodes[0].textContent ?? '') === '')
    )
  }

  if (children.length !== tokens.length) return false

  return tokens.every((token, index) => {
    const child = children[index]
    if (token.type === 'text') {
      return (
        child.nodeType === Node.TEXT_NODE && child.textContent === token.value
      )
    }
    return child.getAttribute?.('data-operand') === token.value
  })
}

/** Operand chips are only "live" when their portal React root is still mounted. */
const chipsHaveLiveRoots = (root, roots) => {
  for (const node of root.childNodes) {
    if (node.getAttribute?.('data-operand') == null) continue
    if (!roots.has(node)) return false
  }
  return true
}

const toFilterExpression = (values) => {
  if (!values?.length) return ''
  const parts = values.map((value) => {
    if (value && typeof value === 'object') {
      return String(value._id ?? value.type ?? JSON.stringify(value))
    }
    return String(value)
  })
  return parts.length === 1 ? parts[0] : parts.join('|')
}

const fromFilterExpression = (expr) => {
  if (expr === undefined || expr === null || expr === '') return []
  if (typeof expr === 'string' && expr.includes('|')) {
    return expr.split('|').filter((part) => part !== '')
  }
  return [expr]
}

const FilterInput = ({
  value = '',
  onChange,
  placeholder = 'Filter…',
  disabled = false,
  allowClear = false,
  className = '',
  style,
  onFocus,
  onBlur,
  onPressEnter,
  size,
  propertyFilter = null
}) => {
  const { getPrefixCls, direction } = useContext(ConfigProvider.ConfigContext)
  const prefixCls = getPrefixCls('input')
  const { token } = theme.useToken()
  const { themeConfig } = useThemeContext()
  const themeConfigRef = useRef(themeConfig)
  themeConfigRef.current = themeConfig

  const rootCls = useCSSVarCls(prefixCls)
  const [wrapSharedCSSVar, hashId, cssVarCls] = useSharedStyle(prefixCls)
  const [wrapCSSVar] = useStyle(prefixCls, rootCls)

  const { compactSize, compactItemClassnames } = useCompactItemContext(
    prefixCls,
    direction
  )
  const mergedSize = useSize((ctx) => size ?? compactSize ?? ctx)

  const editorRef = useRef(null)
  const tagRootsRef = useRef(new Map())
  const focusedRef = useRef(false)
  const composingRef = useRef(false)
  const selectingRef = useRef(false)
  const valueRef = useRef(value ?? '')
  const [focused, setFocused] = useState(false)
  const [internalValue, setInternalValue] = useState(value ?? '')

  const clearTagRoots = useCallback(() => {
    tagRootsRef.current.forEach((root) => {
      queueMicrotask(() => root.unmount())
    })
    tagRootsRef.current.clear()
  }, [])

  const unmountChip = useCallback((wrapper) => {
    const root = tagRootsRef.current.get(wrapper)
    if (root) {
      queueMicrotask(() => root.unmount())
      tagRootsRef.current.delete(wrapper)
    }
  }, [])

  const renderTagNode = useCallback((symbol, themeValue) => {
    const operand = OPERAND_BY_SYMBOL[symbol]
    return (
      <ConfigProvider theme={themeValue}>
        <Tag
          color={operand?.color ?? 'default'}
          bordered={false}
          style={{
            marginInlineEnd: 0,
            marginRight: 0,
            height: '18px',
            cursor: 'default',
            padding: '0 4px',
            marginTop: '-2px',
            lineHeight: 1.37,
            userSelect: 'none',
            pointerEvents: 'none',
            flexShrink: 0,
            fontWeight: 600
          }}
        >
          {operand?.label ?? symbol}
        </Tag>
      </ConfigProvider>
    )
  }, [])

  const renderOperandChip = useCallback(
    (symbol) => {
      const wrapper = document.createElement('span')
      wrapper.setAttribute('data-operand', symbol)
      wrapper.setAttribute('contenteditable', 'false')
      wrapper.style.display = 'inline-flex'
      wrapper.style.alignItems = 'center'
      wrapper.style.verticalAlign = 'middle'
      wrapper.style.margin = '0 2px'
      wrapper.style.userSelect = 'none'
      wrapper.style.flexShrink = '0'

      const root = createRoot(wrapper)
      root.render(renderTagNode(symbol, themeConfigRef.current))
      tagRootsRef.current.set(wrapper, root)
      return wrapper
    },
    [renderTagNode]
  )

  // Portal Tags sit outside the React tree — re-render them when theme changes.
  useEffect(() => {
    tagRootsRef.current.forEach((root, wrapper) => {
      root.render(
        renderTagNode(wrapper.getAttribute('data-operand'), themeConfig)
      )
    })
  }, [themeConfig, renderTagNode])

  const emitChange = useCallback(
    (next) => {
      valueRef.current = next
      setInternalValue(next)
      onChange?.(next)
    },
    [onChange]
  )

  const paint = useCallback(
    (raw, caret = null) => {
      const editor = editorRef.current
      if (!editor) return

      const normalized = normalize(raw)

      // Already in sync with live chip roots — skip to avoid a tag flash.
      if (
        domMatchesTokens(editor, normalized) &&
        chipsHaveLiveRoots(editor, tagRootsRef.current)
      ) {
        if (caret != null && focusedRef.current) {
          requestAnimationFrame(() => {
            setCaretOffset(editor, caret)
            scrollSelectionIntoView(editor)
          })
        }
        return
      }

      const scrollLeft = editor.scrollLeft
      const tokens = tokenize(normalized)

      // Reuse operand chips that still have a mounted React root.
      const chipPool = new Map()
      for (const node of Array.from(editor.childNodes)) {
        const symbol = node.getAttribute?.('data-operand')
        if (symbol == null) continue
        if (!tagRootsRef.current.has(node)) continue
        const list = chipPool.get(symbol)
        if (list) list.push(node)
        else chipPool.set(symbol, [node])
      }

      const takeChip = (symbol) => {
        const list = chipPool.get(symbol)
        while (list?.length) {
          const wrapper = list.shift()
          if (tagRootsRef.current.has(wrapper)) return wrapper
        }
        return renderOperandChip(symbol)
      }

      const nextChildren = []
      if (tokens.length === 0) {
        nextChildren.push(document.createTextNode(''))
      } else {
        for (const item of tokens) {
          if (item.type === 'text') {
            nextChildren.push(document.createTextNode(item.value))
          } else {
            nextChildren.push(takeChip(item.value))
          }
        }
      }

      chipPool.forEach((list) => {
        list.forEach((wrapper) => unmountChip(wrapper))
      })

      editor.replaceChildren(...nextChildren)
      editor.scrollLeft = scrollLeft

      if (caret != null && focusedRef.current) {
        requestAnimationFrame(() => {
          setCaretOffset(editor, caret)
          scrollSelectionIntoView(editor)
        })
      }
    },
    [renderOperandChip, unmountChip]
  )

  useEffect(() => {
    const next = value ?? ''
    valueRef.current = next
    setInternalValue(next)
    if (!focusedRef.current) {
      paint(next)
    }
  }, [value, paint])

  useEffect(() => () => clearTagRoots(), [clearTagRoots])

  const syncFromDom = useCallback(() => {
    const editor = editorRef.current
    if (!editor) return

    const caret = getCaretOffset(editor)
    const serialized = serializeNode(editor)
    const normalized = normalize(serialized)

    if (!domMatchesTokens(editor, normalized)) {
      paint(normalized, caret)
    } else {
      scrollSelectionIntoView(editor)
    }

    if (normalized !== valueRef.current) {
      emitChange(normalized)
    }
  }, [emitChange, paint])

  const findOperandWrapper = (node) => {
    const editor = editorRef.current
    let current = node
    while (current && current !== editor) {
      if (current.getAttribute?.('data-operand') != null) return current
      current = current.parentNode
    }
    return null
  }

  const handleInput = () => {
    if (composingRef.current || disabled) return
    syncFromDom()
  }

  const removeChip = (chip) => {
    const editor = editorRef.current
    if (!editor || !chip) return
    const parent = chip.parentNode
    const index = Array.from(parent.childNodes).indexOf(chip)
    let offset = 0
    for (let i = 0; i < index; i += 1) {
      offset += nodeLength(parent.childNodes[i])
    }
    const root = tagRootsRef.current.get(chip)
    if (root) {
      queueMicrotask(() => root.unmount())
      tagRootsRef.current.delete(chip)
    }
    parent.removeChild(chip)
    setCaretOffset(editor, offset)
    syncFromDom()
  }

  const chipBeforeCaret = () => {
    const editor = editorRef.current
    const selection = window.getSelection()
    if (!editor || !selection?.rangeCount || !selection.isCollapsed) return null
    const { startContainer, startOffset } = selection.getRangeAt(0)

    const insideOperand = findOperandWrapper(startContainer)
    if (insideOperand) return insideOperand

    if (startContainer === editor && startOffset > 0) {
      const prev = editor.childNodes[startOffset - 1]
      if (prev?.getAttribute?.('data-operand') != null) return prev
    }
    if (startContainer.nodeType === Node.TEXT_NODE && startOffset === 0) {
      const prev = startContainer.previousSibling
      if (prev?.getAttribute?.('data-operand') != null) return prev
    }
    return null
  }

  const chipAfterCaret = () => {
    const editor = editorRef.current
    const selection = window.getSelection()
    if (!editor || !selection?.rangeCount || !selection.isCollapsed) return null
    const { startContainer, startOffset } = selection.getRangeAt(0)

    const insideOperand = findOperandWrapper(startContainer)
    if (insideOperand) return insideOperand

    if (startContainer === editor) {
      const next = editor.childNodes[startOffset]
      if (next?.getAttribute?.('data-operand') != null) return next
    }
    if (
      startContainer.nodeType === Node.TEXT_NODE &&
      startOffset === (startContainer.textContent?.length ?? 0)
    ) {
      const next = startContainer.nextSibling
      if (next?.getAttribute?.('data-operand') != null) return next
    }
    return null
  }

  const handleKeyDown = (event) => {
    if (disabled) return

    if (event.key === 'Enter') {
      event.preventDefault()
      onPressEnter?.(internalValue)
      return
    }

    if (event.key === 'Backspace') {
      const chip = chipBeforeCaret()
      if (chip) {
        event.preventDefault()
        removeChip(chip)
      }
      return
    }

    if (event.key === 'Delete') {
      const chip = chipAfterCaret()
      if (chip) {
        event.preventDefault()
        removeChip(chip)
      }
    }
  }

  const handlePaste = (event) => {
    if (disabled) return
    event.preventDefault()
    const text = (event.clipboardData?.getData('text/plain') ?? '').replace(
      /[\r\n]+/g,
      ''
    )
    const editor = editorRef.current
    if (!editor) return

    const selection = window.getSelection()
    if (!selection?.rangeCount) return

    const range = selection.getRangeAt(0)
    const start = getOffsetAt(
      editor,
      range.startContainer,
      range.startOffset,
      'start'
    )
    const end = getOffsetAt(editor, range.endContainer, range.endOffset, 'end')
    const from = Math.min(start, end)
    const to = Math.max(start, end)
    const current = serializeNode(editor)
    const next = `${current.slice(0, from)}${text}${current.slice(to)}`
    const normalized = normalize(next)
    paint(normalized, from + text.length)
    emitChange(normalized)
  }

  const handleCopy = (event) => {
    const editor = editorRef.current
    if (!editor) return
    const selection = window.getSelection()
    if (!selection?.rangeCount || selection.isCollapsed) return
    const text = getSelectedSerializedText(editor)
    if (text == null) return
    event.preventDefault()
    event.clipboardData?.setData('text/plain', text)
  }

  const handleCut = (event) => {
    if (disabled) return
    const editor = editorRef.current
    if (!editor) return
    const selection = window.getSelection()
    if (!selection?.rangeCount || selection.isCollapsed) return

    const text = getSelectedSerializedText(editor)
    if (text == null) return

    event.preventDefault()
    event.clipboardData?.setData('text/plain', text)

    const range = selection.getRangeAt(0)
    const start = getOffsetAt(
      editor,
      range.startContainer,
      range.startOffset,
      'start'
    )
    const end = getOffsetAt(editor, range.endContainer, range.endOffset, 'end')
    const from = Math.min(start, end)
    const to = Math.max(start, end)
    const current = serializeNode(editor)
    const next = `${current.slice(0, from)}${current.slice(to)}`
    const normalized = normalize(next)
    paint(normalized, from)
    emitChange(normalized)
  }

  const handleClear = (event) => {
    event.preventDefault()
    event.stopPropagation()
    if (disabled) return
    paint('')
    emitChange('')
    editorRef.current?.focus()
  }

  const propertyFilterEnabled =
    !disabled &&
    propertyFilter?.modelType != null &&
    propertyFilter?.propertyName != null

  const propertyFilterValue = useMemo(
    () => fromFilterExpression(internalValue),
    [internalValue]
  )

  const handlePropertyFilterChange = useCallback(
    (keys) => {
      const next = toFilterExpression(keys)
      paint(next, focusedRef.current ? next.length : null)
      emitChange(next)
    },
    [emitChange, paint]
  )

  const propertyFilterContent = propertyFilterEnabled ? (
    <div
      onMouseDown={(event) => {
        // Keep the input focused while interacting with the popover.
        event.preventDefault()
      }}
      style={{ width: 280, height: 220, margin: -4 }}
    >
      <ScrollBox inner smallPadding>
        <div style={{ padding: '12px 16px', minWidth: 0 }}>
          <SimplePropertyFilter
            modelType={propertyFilter.modelType}
            propertyName={propertyFilter.propertyName}
            value={propertyFilterValue}
            onChange={handlePropertyFilterChange}
          />
        </div>
      </ScrollBox>
    </div>
  ) : null

  // Auto-scroll while click-dragging a selection past the visible edges.
  useEffect(() => {
    let raf = 0
    let lastClientX = null

    const stopRaf = () => {
      if (raf) {
        cancelAnimationFrame(raf)
        raf = 0
      }
    }

    const tick = () => {
      raf = 0
      if (!selectingRef.current || !focusedRef.current || lastClientX == null) {
        return
      }
      const editor = editorRef.current
      if (!editor) return

      const rect = editor.getBoundingClientRect()
      const edge = 28
      let dx = 0
      if (lastClientX >= rect.right - edge) {
        dx =
          Math.min(28, Math.ceil((lastClientX - (rect.right - edge)) / 2) + 6)
      } else if (lastClientX <= rect.left + edge) {
        dx = -Math.min(
          28,
          Math.ceil((rect.left + edge - lastClientX) / 2) + 6
        )
      }

      if (dx !== 0) {
        const maxScroll = editor.scrollWidth - editor.clientWidth
        const next = Math.max(0, Math.min(maxScroll, editor.scrollLeft + dx))
        if (next !== editor.scrollLeft) {
          editor.scrollLeft = next
          extendSelectionToPoint(
            editor,
            lastClientX,
            rect.top + rect.height / 2
          )
          scrollSelectionIntoView(editor)
          raf = requestAnimationFrame(tick)
        }
      }
    }

    const onMouseMove = (event) => {
      if (!selectingRef.current || !focusedRef.current) return
      lastClientX = event.clientX
      const editor = editorRef.current
      if (!editor) return
      scrollPointIntoView(editor, event.clientX)
      if (!raf) raf = requestAnimationFrame(tick)
    }

    const onMouseUp = () => {
      selectingRef.current = false
      lastClientX = null
      stopRaf()
    }

    const onSelectionChange = () => {
      if (!focusedRef.current) return
      const editor = editorRef.current
      if (!editor) return
      const selection = window.getSelection()
      if (!selection?.rangeCount || !editor.contains(selection.focusNode)) return
      scrollSelectionIntoView(editor)
    }

    document.addEventListener('mousemove', onMouseMove)
    document.addEventListener('mouseup', onMouseUp)
    document.addEventListener('selectionchange', onSelectionChange)
    return () => {
      stopRaf()
      document.removeEventListener('mousemove', onMouseMove)
      document.removeEventListener('mouseup', onMouseUp)
      document.removeEventListener('selectionchange', onSelectionChange)
    }
  }, [])

  const showPlaceholder = !internalValue && !focused
  const showClear = allowClear && !!internalValue && !disabled
  const affixCls = `${prefixCls}-affix-wrapper`
  const placeholderPaddingInline =
    mergedSize === 'large'
      ? (token.paddingInlineLG ?? token.paddingSM + 4)
      : mergedSize === 'small'
        ? (token.paddingInlineSM ?? token.paddingXS)
        : (token.paddingInline ?? token.paddingSM)

  const input = (
    <span
      className={classNames(
        affixCls,
        {
          [`${affixCls}-focused`]: focused,
          [`${affixCls}-disabled`]: disabled,
          [`${affixCls}-sm`]: mergedSize === 'small',
          [`${affixCls}-lg`]: mergedSize === 'large',
          [`${affixCls}-rtl`]: direction === 'rtl',
          [`${prefixCls}-outlined`]: true
        },
        compactItemClassnames,
        cssVarCls,
        rootCls,
        hashId,
        className
      )}
      style={{
        width: '100%',
        cursor: disabled ? 'not-allowed' : 'text',
        ...style,
        // Affix ::before strut needs inline-flex; display:block stacks it and inflates height.
        display: 'inline-flex',
        // Stay above Compact siblings so the focus ring isn't covered by Select/Button.
        position: 'relative',
        zIndex: focused ? 3 : style?.zIndex
      }}
      onClick={() => {
        if (!disabled) editorRef.current?.focus()
      }}
    >
      <style>{`
        .filter-input-editor::-webkit-scrollbar {
          display: none;
          height: 0;
        }
      `}</style>
      {showPlaceholder && (
        <span
          aria-hidden
          style={{
            position: 'absolute',
            insetInlineStart: placeholderPaddingInline,
            insetInlineEnd: showClear
              ? placeholderPaddingInline + 22
              : placeholderPaddingInline,
            top: 0,
            bottom: 0,
            display: 'flex',
            alignItems: 'center',
            color: token.colorTextPlaceholder,
            pointerEvents: 'none',
            whiteSpace: 'nowrap',
            overflow: 'hidden',
            textOverflow: 'ellipsis',
            zIndex: 1
          }}
        >
          {placeholder}
        </span>
      )}
      <div
        ref={editorRef}
        className={classNames(prefixCls, 'filter-input-editor')}
        role='textbox'
        aria-multiline='false'
        aria-disabled={disabled || undefined}
        contentEditable={!disabled}
        suppressContentEditableWarning
        spellCheck={false}
        onInput={handleInput}
        onKeyDown={handleKeyDown}
        onPaste={handlePaste}
        onCopy={handleCopy}
        onCut={handleCut}
        onMouseDown={() => {
          selectingRef.current = true
        }}
        onCompositionStart={() => {
          composingRef.current = true
        }}
        onCompositionEnd={() => {
          composingRef.current = false
          syncFromDom()
        }}
        onFocus={(event) => {
          focusedRef.current = true
          setFocused(true)
          onFocus?.(event)
        }}
        onBlur={(event) => {
          focusedRef.current = false
          selectingRef.current = false
          setFocused(false)
          const next = normalize(serializeNode(editorRef.current))
          paint(next)
          if (next !== valueRef.current) emitChange(next)
          onBlur?.(event)
        }}
        style={{
          // Match antd's `> input.ant-input` resets inside affix-wrapper
          display: 'block',
          padding: 0,
          border: 'none',
          borderRadius: 0,
          outline: 'none',
          boxShadow: 'none',
          background: 'transparent',
          flex: 'auto',
          minWidth: 0,
          width: '100%',
          fontSize: 'inherit',
          lineHeight: 'inherit',
          color: 'inherit',
          whiteSpace: 'nowrap',
          overflowX: 'auto',
          overflowY: 'hidden',
          scrollbarWidth: 'none',
          msOverflowStyle: 'none',
          wordBreak: 'keep-all',
          overflowWrap: 'normal',
          caretColor: token.colorText,
          cursor: disabled ? 'not-allowed' : 'text'
        }}
      />
      {showClear && (
        <span className={`${prefixCls}-suffix`}>
          <span
            className={classNames(
              `${prefixCls}-clear-icon`,
              `${prefixCls}-clear-icon-has-value`
            )}
            role='button'
            tabIndex={-1}
            onMouseDown={handleClear}
          >
            <CloseCircleFilled />
          </span>
        </span>
      )}
      {propertyFilterEnabled && (
        <Popover
          open={focused}
          content={propertyFilterContent}
          placement='bottomLeft'
          arrow={false}
          trigger={[]}
          styles={{
            body: {
              padding: 8
            }
          }}
        >
          {/* Anchor only — keep Popover from wrapping the Compact item (z-index/focus ring). */}
          <span
            aria-hidden
            style={{
              position: 'absolute',
              left: 0,
              right: 0,
              bottom: 0,
              height: 0,
              pointerEvents: 'none'
            }}
          />
        </Popover>
      )}
    </span>
  )

  return wrapSharedCSSVar(wrapCSSVar(input))
}

FilterInput.propTypes = {
  value: PropTypes.string,
  onChange: PropTypes.func,
  placeholder: PropTypes.string,
  disabled: PropTypes.bool,
  allowClear: PropTypes.bool,
  className: PropTypes.string,
  style: PropTypes.object,
  onFocus: PropTypes.func,
  onBlur: PropTypes.func,
  onPressEnter: PropTypes.func,
  size: PropTypes.oneOf(['small', 'middle', 'large']),
  propertyFilter: PropTypes.shape({
    modelType: PropTypes.string.isRequired,
    propertyName: PropTypes.string.isRequired
  })
}

export default FilterInput
export { OPERANDS, tokenize }
