import {
  type Middleware,
  arrow,
  autoPlacement,
  computePosition,
  flip,
  hide,
  inline,
  offset,
  shift,
  size,
} from '@floating-ui/dom'
import type { Editor } from '@tiptap/core'
import { getText, getTextSerializersFromSchema, posToDOMRect } from '@tiptap/core'
import type { Node as ProsemirrorNode } from '@tiptap/pm/model'
import type { EditorState, Transaction } from '@tiptap/pm/state'
import { Plugin, PluginKey } from '@tiptap/pm/state'
import type { EditorView } from '@tiptap/pm/view'

export interface FloatingMenuPluginProps {
  /**
   * The plugin key for the floating menu.
   * @default 'floatingMenu'
   */
  pluginKey: PluginKey | string

  /**
   * The editor instance.
   * @default null
   */
  editor: Editor

  /**
   * The DOM element that contains your menu.
   * @default null
   */
  element: HTMLElement

  /**
   * The delay in milliseconds before the menu should be updated.
   * This can be useful to prevent performance issues.
   * @type {number}
   * @default 250
   */
  updateDelay?: number

  /**
   * The delay in milliseconds before the menu position should be updated on window resize.
   * This can be useful to prevent performance issues.
   * @type {number}
   * @default 60
   */
  resizeDelay?: number

  /**
   * The DOM element to append your menu to. Default is the editor's parent element.
   *
   * Sometimes the menu needs to be appended to a different DOM context due to accessibility, clipping, or z-index issues.
   *
   * @type {HTMLElement}
   * @default null
   */
  appendTo?: HTMLElement | (() => HTMLElement)

  /**
   * A function that determines whether the menu should be shown or not.
   * If this function returns `false`, the menu will be hidden, otherwise it will be shown.
   */
  shouldShow?:
    | ((props: {
        editor: Editor
        view: EditorView
        state: EditorState
        oldState?: EditorState
        from: number
        to: number
      }) => boolean)
    | null

  /**
   * The options for the floating menu. Those are passed to Floating UI and include options for the placement, offset, flip, shift, arrow, size, autoPlacement,
   * hide, and inline middlewares.
   * @default {}
   * @see https://floating-ui.com/docs/computePosition#options
   */
  options?: {
    strategy?: 'absolute' | 'fixed'
    placement?:
      | 'top'
      | 'right'
      | 'bottom'
      | 'left'
      | 'top-start'
      | 'top-end'
      | 'right-start'
      | 'right-end'
      | 'bottom-start'
      | 'bottom-end'
      | 'left-start'
      | 'left-end'
    offset?: Parameters<typeof offset>[0] | boolean
    flip?: Parameters<typeof flip>[0] | boolean
    shift?: Parameters<typeof shift>[0] | boolean
    arrow?: Parameters<typeof arrow>[0] | false
    size?: Parameters<typeof size>[0] | boolean
    autoPlacement?: Parameters<typeof autoPlacement>[0] | boolean
    hide?: Parameters<typeof hide>[0] | boolean
    inline?: Parameters<typeof inline>[0] | boolean

    onShow?: () => void
    onHide?: () => void
    onUpdate?: () => void
    onDestroy?: () => void

    /**
     * The scrollable element that should be listened to when updating the position of the floating menu.
     * If not provided, the window will be used.
     * @type {HTMLElement | Window}
     */
    scrollTarget?: HTMLElement | Window
  }
}

export type FloatingMenuViewProps = FloatingMenuPluginProps & {
  /**
   * The editor view.
   */
  view: EditorView
}

export class FloatingMenuView {
  public editor: Editor

  public element: HTMLElement

  public view: EditorView

  public preventHide = false

  public pluginKey: PluginKey | string

  /**
   * The delay in milliseconds before the menu should be updated.
   * @default 250
   */
  public updateDelay: number

  /**
   * The delay in milliseconds before the menu position should be updated on window resize.
   * @default 60
   */
  public resizeDelay: number

  public appendTo: HTMLElement | (() => HTMLElement) | undefined

  private updateDebounceTimer: number | undefined

  private resizeDebounceTimer: number | undefined

  private isVisible = false

  private scrollTarget: HTMLElement | Window = window

  private getTextContent(node: ProsemirrorNode) {
    return getText(node, { textSerializers: getTextSerializersFromSchema(this.editor.schema) })
  }

  public shouldShow: Exclude<FloatingMenuPluginProps['shouldShow'], null> = ({ view, state }) => {
    const { selection } = state
    const { $anchor, empty } = selection
    const isRootDepth = $anchor.depth === 1

    const isEmptyTextBlock =
      $anchor.parent.isTextblock &&
      !$anchor.parent.type.spec.code &&
      !$anchor.parent.textContent &&
      $anchor.parent.childCount === 0 &&
      !this.getTextContent($anchor.parent)

    if (!view.hasFocus() || !empty || !isRootDepth || !isEmptyTextBlock || !this.editor.isEditable) {
      return false
    }

    return true
  }

  private floatingUIOptions: NonNullable<FloatingMenuPluginProps['options']> = {
    strategy: 'absolute',
    placement: 'right',
    offset: 8,
    flip: {},
    shift: {},
    arrow: false,
    size: false,
    autoPlacement: false,
    hide: false,
    inline: false,
  }

  get middlewares() {
    const middlewares: Middleware[] = []

    if (this.floatingUIOptions.flip) {
      middlewares.push(flip(typeof this.floatingUIOptions.flip !== 'boolean' ? this.floatingUIOptions.flip : undefined))
    }

    if (this.floatingUIOptions.shift) {
      middlewares.push(
        shift(typeof this.floatingUIOptions.shift !== 'boolean' ? this.floatingUIOptions.shift : undefined),
      )
    }

    if (this.floatingUIOptions.offset) {
      middlewares.push(
        offset(typeof this.floatingUIOptions.offset !== 'boolean' ? this.floatingUIOptions.offset : undefined),
      )
    }

    if (this.floatingUIOptions.arrow) {
      middlewares.push(arrow(this.floatingUIOptions.arrow))
    }

    if (this.floatingUIOptions.size) {
      middlewares.push(size(typeof this.floatingUIOptions.size !== 'boolean' ? this.floatingUIOptions.size : undefined))
    }

    if (this.floatingUIOptions.autoPlacement) {
      middlewares.push(
        autoPlacement(
          typeof this.floatingUIOptions.autoPlacement !== 'boolean' ? this.floatingUIOptions.autoPlacement : undefined,
        ),
      )
    }

    if (this.floatingUIOptions.hide) {
      middlewares.push(hide(typeof this.floatingUIOptions.hide !== 'boolean' ? this.floatingUIOptions.hide : undefined))
    }

    if (this.floatingUIOptions.inline) {
      middlewares.push(
        inline(typeof this.floatingUIOptions.inline !== 'boolean' ? this.floatingUIOptions.inline : undefined),
      )
    }

    return middlewares
  }

  constructor({
    editor,
    element,
    view,
    pluginKey = 'floatingMenu',
    updateDelay = 250,
    resizeDelay = 60,
    options,
    appendTo,
    shouldShow,
  }: FloatingMenuViewProps) {
    this.editor = editor
    this.element = element
    this.view = view
    this.pluginKey = pluginKey
    this.updateDelay = updateDelay
    this.resizeDelay = resizeDelay
    this.appendTo = appendTo
    this.scrollTarget = options?.scrollTarget ?? window

    this.floatingUIOptions = {
      ...this.floatingUIOptions,
      ...options,
    }

    this.element.tabIndex = 0

    if (shouldShow) {
      this.shouldShow = shouldShow
    }

    this.element.addEventListener('mousedown', this.mousedownHandler, { capture: true })
    this.editor.on('focus', this.focusHandler)
    this.editor.on('blur', this.blurHandler)
    this.editor.on('transaction', this.transactionHandler)
    window.addEventListener('resize', this.resizeHandler)
    this.scrollTarget.addEventListener('scroll', this.resizeHandler)

    this.update(view, view.state)

    if (this.getShouldShow()) {
      this.show()
      this.updatePosition()
    }
  }

  getShouldShow(oldState?: EditorState) {
    const { state } = this.view
    const { selection } = state

    const { ranges } = selection
    const from = Math.min(...ranges.map(range => range.$from.pos))
    const to = Math.max(...ranges.map(range => range.$to.pos))

    const shouldShow = this.shouldShow?.({
      editor: this.editor,
      view: this.view,
      state,
      oldState,
      from,
      to,
    })

    return shouldShow
  }

  updateHandler = (view: EditorView, selectionChanged: boolean, docChanged: boolean, oldState?: EditorState) => {
    const { composing } = view

    const isSame = !selectionChanged && !docChanged

    if (composing || isSame) {
      return
    }

    const shouldShow = this.getShouldShow(oldState)

    if (!shouldShow) {
      this.hide()

      return
    }

    this.updatePosition()
    this.show()
  }

  mousedownHandler = () => {
    this.preventHide = true
  }

  focusHandler = () => {
    // we use `setTimeout` to make sure `selection` is already updated
    setTimeout(() => this.update(this.editor.view))
  }

  blurHandler = ({ event }: { event: FocusEvent }) => {
    if (this.preventHide) {
      this.preventHide = false

      return
    }

    if (event?.relatedTarget && this.element.parentNode?.contains(event.relatedTarget as Node)) {
      return
    }

    if (event?.relatedTarget === this.editor.view.dom) {
      return
    }

    this.hide()
  }

  /**
   * Handles the transaction event to update the position of the floating menu.
   * This allows external code to trigger a position update via:
   * `editor.view.dispatch(editor.state.tr.setMeta(pluginKey, 'updatePosition'))`
   * The `pluginKey` defaults to `floatingMenu`
   */
  transactionHandler = ({ transaction: tr }: { transaction: Transaction }) => {
    const meta = tr.getMeta(this.pluginKey)
    if (meta === 'updatePosition') {
      this.updatePosition()
    } else if (meta && typeof meta === 'object' && meta.type === 'updateOptions') {
      this.updateOptions(meta.options)
    } else if (meta === 'hide') {
      this.hide()
    } else if (meta === 'show') {
      this.updatePosition()
      this.show()
    }
  }

  updateOptions(newProps: Partial<Omit<FloatingMenuPluginProps, 'editor' | 'element' | 'pluginKey'>>) {
    if (newProps.updateDelay !== undefined) {
      this.updateDelay = newProps.updateDelay
    }

    if (newProps.resizeDelay !== undefined) {
      this.resizeDelay = newProps.resizeDelay
    }

    if (newProps.appendTo !== undefined) {
      this.appendTo = newProps.appendTo
    }

    if (newProps.shouldShow !== undefined) {
      if (newProps.shouldShow) {
        this.shouldShow = newProps.shouldShow
      }
    }

    if (newProps.options !== undefined) {
      // Handle scrollTarget change - need to remove old listener and add new one
      // Use nullish coalescing to default to window when scrollTarget is undefined/null
      const newScrollTarget = newProps.options.scrollTarget ?? window

      if (newScrollTarget !== this.scrollTarget) {
        this.scrollTarget.removeEventListener('scroll', this.resizeHandler)
        this.scrollTarget = newScrollTarget
        this.scrollTarget.addEventListener('scroll', this.resizeHandler)
      }

      this.floatingUIOptions = {
        ...this.floatingUIOptions,
        ...newProps.options,
      }
    }
  }

  /**
   * Handles the window resize event to update the position of the floating menu.
   * It uses a debounce mechanism to prevent excessive updates.
   * The delay is defined by the `resizeDelay` property.
   */
  resizeHandler = () => {
    if (this.resizeDebounceTimer) {
      clearTimeout(this.resizeDebounceTimer)
    }

    this.resizeDebounceTimer = window.setTimeout(() => {
      this.updatePosition()
    }, this.resizeDelay)
  }

  updatePosition() {
    const { selection } = this.editor.state

    const domRect = posToDOMRect(this.view, selection.from, selection.to)

    const virtualElement = {
      getBoundingClientRect: () => domRect,
      getClientRects: () => [domRect],
    }

    computePosition(virtualElement, this.element, {
      placement: this.floatingUIOptions.placement,
      strategy: this.floatingUIOptions.strategy,
      middleware: this.middlewares,
    }).then(({ x, y, strategy, middlewareData }) => {
      // Handle hide middleware - hide element if reference is hidden or element has escaped
      if (middlewareData.hide?.referenceHidden || middlewareData.hide?.escaped) {
        this.element.style.visibility = 'hidden'
        return
      }

      this.element.style.visibility = 'visible'
      this.element.style.width = 'max-content'
      this.element.style.position = strategy
      this.element.style.left = `${x}px`
      this.element.style.top = `${y}px`

      if (this.isVisible && this.floatingUIOptions.onUpdate) {
        this.floatingUIOptions.onUpdate()
      }
    })
  }

  update(view: EditorView, oldState?: EditorState) {
    const selectionChanged = !oldState?.selection.eq(view.state.selection)
    const docChanged = !oldState?.doc.eq(view.state.doc)

    this.updateHandler(view, selectionChanged, docChanged, oldState)
  }

  show() {
    if (this.isVisible) {
      return
    }

    this.element.style.visibility = 'visible'
    this.element.style.opacity = '1'

    // attach to appendTo or editor's parent element
    const appendToElement = typeof this.appendTo === 'function' ? this.appendTo() : this.appendTo
    ;(appendToElement ?? this.view.dom.parentElement)?.appendChild(this.element)

    if (this.floatingUIOptions.onShow) {
      this.floatingUIOptions.onShow()
    }

    this.isVisible = true
  }

  hide() {
    if (!this.isVisible) {
      return
    }

    this.element.style.visibility = 'hidden'
    this.element.style.opacity = '0'
    // remove from the parent element
    this.element.remove()

    if (this.floatingUIOptions.onHide) {
      this.floatingUIOptions.onHide()
    }

    this.isVisible = false
  }

  destroy() {
    this.hide()
    this.element.removeEventListener('mousedown', this.mousedownHandler, { capture: true })
    window.removeEventListener('resize', this.resizeHandler)
    this.scrollTarget.removeEventListener('scroll', this.resizeHandler)
    this.editor.off('focus', this.focusHandler)
    this.editor.off('blur', this.blurHandler)
    this.editor.off('transaction', this.transactionHandler)

    if (this.floatingUIOptions.onDestroy) {
      this.floatingUIOptions.onDestroy()
    }
  }
}

export const FloatingMenuPlugin = (options: FloatingMenuPluginProps) => {
  return new Plugin({
    key: typeof options.pluginKey === 'string' ? new PluginKey(options.pluginKey) : options.pluginKey,
    view: view => new FloatingMenuView({ view, ...options }),
  })
}
