import {
  computed,
  getCurrentInstance,
  h,
  nextTick,
  onBeforeUnmount,
  onMounted,
  ref,
  watch
} from 'vue'

import Caret from './editor-caret.js'
import {
  dropdownContentClass,
  getFonts,
  getLinkEditor,
  getToolbar
} from './editor-utils.js'

import useQuasar from '../../composables/use-quasar/use-quasar.js'
import useDark, {
  useDarkProps
} from '../../composables/private.use-dark/use-dark.js'
import useFullscreen, {
  useFullscreenEmits,
  useFullscreenProps
} from '../../composables/private.use-fullscreen/use-fullscreen.js'
import useSplitAttrs from '../../composables/use-split-attrs/use-split-attrs.js'

import { isRuntimeSsrPreHydration } from '../../plugins/platform/Platform.js'

import { createComponent } from '../../utils/private.create/create.js'
import { stopAndPrevent } from '../../utils/event/event.js'
import extend from '../../utils/extend/extend.js'
import { shouldIgnoreKey } from '../../utils/private.keyboard/key-composition.js'
import { addFocusFn } from '../../utils/private.focus/focus-manager.js'

export default /*#__PURE__*/ createComponent({
  name: 'QEditor',

  inheritAttrs: false,

  props: {
    ...useDarkProps,
    ...useFullscreenProps,

    modelValue: {
      type: String,
      required: true
    },
    readonly: Boolean,
    disable: Boolean,
    minHeight: {
      type: String,
      default: '10rem'
    },
    maxHeight: String,
    height: String,
    definitions: Object,
    fonts: Object,
    placeholder: String,

    toolbar: {
      type: Array,
      validator: v => v.every(group => group.length),
      // long line on purpose for API validation purposes:
      // oxfmt-ignore
      default: () => [['left', 'center', 'right', 'justify'], ['bold', 'italic', 'underline', 'strike'], ['undo', 'redo']]
    },
    toolbarColor: String,
    toolbarBg: String,
    toolbarTextColor: String,
    toolbarToggleColor: {
      type: String,
      default: 'primary'
    },
    toolbarOutline: Boolean,
    toolbarPush: Boolean,
    toolbarRounded: Boolean,

    paragraphTag: {
      type: String,
      validator: v => ['div', 'p'].includes(v),
      default: 'div'
    },

    contentStyle: Object,
    contentClass: [Object, Array, String],

    square: Boolean,
    flat: Boolean,
    dense: Boolean
  },

  emits: [
    ...useFullscreenEmits,
    'update:modelValue',
    'keydown',
    'click',
    'focus',
    'blur',
    'dropdownShow',
    'dropdownHide',
    'dropdownBeforeShow',
    'dropdownBeforeHide',
    'linkShow',
    'linkHide'
  ],

  setup(props, { slots, emit, attrs }) {
    const { proxy } = getCurrentInstance()
    const $q = useQuasar()

    const isDark = useDark(props, $q)
    const { inFullscreen, toggleFullscreen } = useFullscreen()
    const splitAttrs = useSplitAttrs()

    const rootRef = ref(null)
    const contentRef = ref(null)

    const editLinkUrl = ref(null)
    const isViewingSource = ref(false)

    // flat index of the toolbar's roving Tab stop; the effective stop is
    // resolved at render time (see getToolbar()), so a stale index -- the
    // control got disabled or filtered out -- heals itself
    const toolbarTabStop = ref(null)

    const editable = computed(() => !props.readonly && !props.disable)

    let defaultFont,
      offsetBottom,
      refreshToolbarTimer = null
    let lastEmit = props.modelValue
    const renderInitialContent =
      __QUASAR_SSR_SERVER__ || isRuntimeSsrPreHydration.value

    if (!__QUASAR_SSR_SERVER__) {
      document.execCommand(
        'defaultParagraphSeparator',
        false,
        props.paragraphTag
      )
      defaultFont = window.getComputedStyle(document.body).fontFamily
    }

    const toolbarBackgroundClass = computed(() =>
      props.toolbarBg ? ` bg-${props.toolbarBg}` : ''
    )

    const buttonProps = computed(() => {
      const flat = !props.toolbarOutline && !props.toolbarPush

      return {
        type: 'a',
        flat,
        noWrap: true,
        outline: props.toolbarOutline,
        push: props.toolbarPush,
        rounded: props.toolbarRounded,
        dense: true,
        color: props.toolbarColor,
        disable: !editable.value,
        size: 'sm'
      }
    })

    const buttonDef = computed(() => {
      const e = $q.lang.editor,
        i = $q.iconSet.editor

      return {
        bold: { cmd: 'bold', icon: i.bold, tip: e.bold, key: 66 },
        italic: { cmd: 'italic', icon: i.italic, tip: e.italic, key: 73 },
        strike: {
          cmd: 'strikeThrough',
          icon: i.strikethrough,
          tip: e.strikethrough,
          key: 83
        },
        underline: {
          cmd: 'underline',
          icon: i.underline,
          tip: e.underline,
          key: 85
        },
        unordered: {
          cmd: 'insertUnorderedList',
          icon: i.unorderedList,
          tip: e.unorderedList
        },
        ordered: {
          cmd: 'insertOrderedList',
          icon: i.orderedList,
          tip: e.orderedList
        },
        subscript: {
          cmd: 'subscript',
          icon: i.subscript,
          tip: e.subscript,
          htmlTip: 'x<subscript>2</subscript>'
        },
        superscript: {
          cmd: 'superscript',
          icon: i.superscript,
          tip: e.superscript,
          htmlTip: 'x<superscript>2</superscript>'
        },
        link: {
          cmd: 'link',
          disable: eVm => eVm.caret && !eVm.caret.can('link'),
          icon: i.hyperlink,
          tip: e.hyperlink,
          key: 76
        },
        fullscreen: {
          cmd: 'fullscreen',
          icon: i.toggleFullscreen,
          tip: e.toggleFullscreen,
          key: 70
        },
        viewsource: {
          cmd: 'viewsource',
          icon: i.viewSource,
          tip: e.viewSource
        },

        quote: {
          cmd: 'formatBlock',
          param: 'BLOCKQUOTE',
          icon: i.quote,
          tip: e.quote,
          key: 81
        },
        left: { cmd: 'justifyLeft', icon: i.left, tip: e.left },
        center: { cmd: 'justifyCenter', icon: i.center, tip: e.center },
        right: { cmd: 'justifyRight', icon: i.right, tip: e.right },
        justify: { cmd: 'justifyFull', icon: i.justify, tip: e.justify },

        print: {
          type: 'no-state',
          cmd: 'print',
          icon: i.print,
          tip: e.print,
          key: 80
        },
        outdent: {
          type: 'no-state',
          disable: eVm => eVm.caret && !eVm.caret.can('outdent'),
          cmd: 'outdent',
          icon: i.outdent,
          tip: e.outdent
        },
        indent: {
          type: 'no-state',
          disable: eVm => eVm.caret && !eVm.caret.can('indent'),
          cmd: 'indent',
          icon: i.indent,
          tip: e.indent
        },
        removeFormat: {
          type: 'no-state',
          cmd: 'removeFormat',
          icon: i.removeFormat,
          tip: e.removeFormat
        },
        hr: {
          type: 'no-state',
          cmd: 'insertHorizontalRule',
          icon: i.hr,
          tip: e.hr
        },
        undo: {
          type: 'no-state',
          cmd: 'undo',
          icon: i.undo,
          tip: e.undo,
          key: 90
        },
        redo: {
          type: 'no-state',
          cmd: 'redo',
          icon: i.redo,
          tip: e.redo,
          key: 89
        },

        h1: {
          cmd: 'formatBlock',
          param: 'H1',
          icon: i.heading1 || i.heading,
          tip: e.heading1,
          htmlTip: `<h1 class="q-ma-none">${e.heading1}</h1>`
        },
        h2: {
          cmd: 'formatBlock',
          param: 'H2',
          icon: i.heading2 || i.heading,
          tip: e.heading2,
          htmlTip: `<h2 class="q-ma-none">${e.heading2}</h2>`
        },
        h3: {
          cmd: 'formatBlock',
          param: 'H3',
          icon: i.heading3 || i.heading,
          tip: e.heading3,
          htmlTip: `<h3 class="q-ma-none">${e.heading3}</h3>`
        },
        h4: {
          cmd: 'formatBlock',
          param: 'H4',
          icon: i.heading4 || i.heading,
          tip: e.heading4,
          htmlTip: `<h4 class="q-ma-none">${e.heading4}</h4>`
        },
        h5: {
          cmd: 'formatBlock',
          param: 'H5',
          icon: i.heading5 || i.heading,
          tip: e.heading5,
          htmlTip: `<h5 class="q-ma-none">${e.heading5}</h5>`
        },
        h6: {
          cmd: 'formatBlock',
          param: 'H6',
          icon: i.heading6 || i.heading,
          tip: e.heading6,
          htmlTip: `<h6 class="q-ma-none">${e.heading6}</h6>`
        },
        p: {
          cmd: 'formatBlock',
          param: props.paragraphTag,
          icon: i.heading,
          tip: e.paragraph
        },
        code: {
          cmd: 'formatBlock',
          param: 'PRE',
          icon: i.code,
          htmlTip: `<code>${e.code}</code>`
        },

        'size-1': {
          cmd: 'fontSize',
          param: '1',
          // oxlint-disable-next-line unicorn/explicit-length-check
          icon: i.size1 || i.size,
          tip: e.size1,
          htmlTip: `<font size="1">${e.size1}</font>`
        },
        'size-2': {
          cmd: 'fontSize',
          param: '2',
          // oxlint-disable-next-line unicorn/explicit-length-check
          icon: i.size2 || i.size,
          tip: e.size2,
          htmlTip: `<font size="2">${e.size2}</font>`
        },
        'size-3': {
          cmd: 'fontSize',
          param: '3',
          // oxlint-disable-next-line unicorn/explicit-length-check
          icon: i.size3 || i.size,
          tip: e.size3,
          htmlTip: `<font size="3">${e.size3}</font>`
        },
        'size-4': {
          cmd: 'fontSize',
          param: '4',
          // oxlint-disable-next-line unicorn/explicit-length-check
          icon: i.size4 || i.size,
          tip: e.size4,
          htmlTip: `<font size="4">${e.size4}</font>`
        },
        'size-5': {
          cmd: 'fontSize',
          param: '5',
          // oxlint-disable-next-line unicorn/explicit-length-check
          icon: i.size5 || i.size,
          tip: e.size5,
          htmlTip: `<font size="5">${e.size5}</font>`
        },
        'size-6': {
          cmd: 'fontSize',
          param: '6',
          // oxlint-disable-next-line unicorn/explicit-length-check
          icon: i.size6 || i.size,
          tip: e.size6,
          htmlTip: `<font size="6">${e.size6}</font>`
        },
        'size-7': {
          cmd: 'fontSize',
          param: '7',
          // oxlint-disable-next-line unicorn/explicit-length-check
          icon: i.size7 || i.size,
          tip: e.size7,
          htmlTip: `<font size="7">${e.size7}</font>`
        }
      }
    })

    const buttons = computed(() => {
      const userDef = props.definitions || {}
      const def =
        props.definitions || props.fonts
          ? extend(
              true,
              {},
              buttonDef.value,
              userDef,
              getFonts(
                defaultFont,
                $q.lang.editor.defaultFont,
                $q.iconSet.editor.font,
                props.fonts
              )
            )
          : buttonDef.value

      return props.toolbar.map(group =>
        group.map(token => {
          if (token.options) {
            return {
              type: 'dropdown',
              icon: token.icon,
              label: token.label,
              size: 'sm',
              dense: true,
              fixedLabel: token.fixedLabel,
              fixedIcon: token.fixedIcon,
              highlight: token.highlight,
              list: token.list,
              // an unknown option is ignored, the same way that
              // an unknown plain token is
              options: token.options
                .map(item => def[item])
                .filter(item => item !== void 0)
            }
          }

          const obj = def[token]

          if (obj) {
            return obj.type === 'no-state' ||
              (userDef[token] &&
                (obj.cmd === void 0 ||
                  (buttonDef.value[obj.cmd] &&
                    buttonDef.value[obj.cmd].type === 'no-state')))
              ? obj
              : { type: 'toggle', ...obj }
          }

          return {
            type: 'slot',
            slot: token
          }
        })
      )
    })

    const eVm = {
      $q,
      props,
      slots,
      emit,
      // caret (will get injected after mount)
      inFullscreen,
      toggleFullscreen,
      runCmd,
      isViewingSource,
      editLinkUrl,
      toolbarTabStop,
      toolbarBackgroundClass,
      buttonProps,
      contentRef,
      buttons,
      setContent
    }

    watch(
      () => props.modelValue,
      v => {
        if (lastEmit !== v) {
          lastEmit = v
          setContent(v, true)
        }
      }
    )

    watch(editLinkUrl, v => {
      emit(`link${v ? 'Show' : 'Hide'}`)
    })

    const hasToolbar = computed(
      () => props.toolbar && props.toolbar.length !== 0
    )

    const keys = computed(() => {
      const k = {},
        add = btn => {
          if (btn.key) {
            k[btn.key] = {
              cmd: btn.cmd,
              param: btn.param
            }
          }
        }

      buttons.value.forEach(group => {
        group.forEach(token => {
          if (token.options) {
            token.options.forEach(add)
          } else {
            add(token)
          }
        })
      })
      return k
    })

    const innerStyle = computed(() =>
      inFullscreen.value
        ? props.contentStyle
        : [
            {
              minHeight: props.minHeight,
              height: props.height,
              maxHeight: props.maxHeight
            },
            props.contentStyle
          ]
    )

    const classes = computed(
      () =>
        `q-editor q-editor--${isViewingSource.value ? 'source' : 'default'}` +
        (props.disable ? ' disabled' : '') +
        (inFullscreen.value ? ' fullscreen column' : '') +
        (props.square ? ' q-editor--square no-border-radius' : '') +
        (props.flat ? ' q-editor--flat' : '') +
        (props.dense ? ' q-editor--dense' : '') +
        (isDark() ? ' q-editor--dark q-dark' : '')
    )

    const innerClass = computed(() => [
      props.contentClass,
      'q-editor__content',
      {
        col: inFullscreen.value,
        'overflow-auto': inFullscreen.value || props.maxHeight
      }
    ])

    const contentAttributes = computed(() => {
      const acc = {
        role: 'textbox',
        'aria-multiline': 'true'
      }

      if (props.placeholder !== void 0) {
        acc['aria-placeholder'] = props.placeholder
      }

      if (props.readonly) {
        acc['aria-readonly'] = 'true'
      }

      if (props.disable) {
        acc['aria-disabled'] = 'true'
      }

      // consumer-supplied attributes (aria-label, id, ...) win
      return Object.assign(acc, splitAttrs.attributes.value)
    })

    function onInput() {
      if (contentRef.value !== null) {
        const prop = `inner${isViewingSource.value ? 'Text' : 'HTML'}`
        const val = contentRef.value[prop]

        if (val !== props.modelValue) {
          lastEmit = val
          emit('update:modelValue', val)
        }
      }
    }

    function onKeydown(e) {
      emit('keydown', e)

      if (!e.ctrlKey || e.defaultPrevented || shouldIgnoreKey(e)) {
        refreshToolbar()
        return
      }

      const key = e.keyCode
      const target = keys.value[key]
      if (target !== void 0) {
        const { cmd, param } = target
        stopAndPrevent(e)
        runCmd(cmd, param, false)
      }
    }

    function onClick(e) {
      refreshToolbar()
      emit('click', e)
    }

    function onBlur(e) {
      if (contentRef.value !== null) {
        const { scrollTop, scrollHeight } = contentRef.value
        offsetBottom = scrollHeight - scrollTop
      }
      eVm.caret.save()
      emit('blur', e)
    }

    function onFocus(e) {
      nextTick(() => {
        if (contentRef.value !== null && offsetBottom !== void 0) {
          contentRef.value.scrollTop =
            contentRef.value.scrollHeight - offsetBottom
        }
      })
      emit('focus', e)
    }

    /**
     * Tells a real focus change apart from focus merely travelling between
     * the editor and one of its own toolbar dropdowns: those render their
     * menu through a portal, so the DOM alone reports them as elsewhere.
     */
    function movedOutside(el) {
      const root = rootRef.value

      return (
        el === null ||
        (!root.contains(el) && !el.closest?.(`.${dropdownContentClass}`))
      )
    }

    function onFocusin(e) {
      const root = rootRef.value

      if (
        root !== null &&
        root.contains(e.target) &&
        movedOutside(e.relatedTarget)
      ) {
        const prop = `inner${isViewingSource.value ? 'Text' : 'HTML'}`
        eVm.caret.restorePosition(contentRef.value[prop].length)
        refreshToolbar()
      }
    }

    function onFocusout(e) {
      const root = rootRef.value

      if (
        root !== null &&
        root.contains(e.target) &&
        movedOutside(e.relatedTarget)
      ) {
        eVm.caret.savePosition()
        refreshToolbar()
      }
    }

    function onPointerStart() {
      offsetBottom = void 0
    }

    function onToolbarFocusin(e) {
      const control = e.target.closest?.('[data-tbi]')

      if (control) {
        const index = Number(control.dataset.tbi)
        if (toolbarTabStop.value !== index) {
          toolbarTabStop.value = index
        }
      }
    }

    // WAI-ARIA toolbar pattern: Left/Right arrows (mirrored in RTL) move
    // between the toolbar's controls, Home/End jump to its edges
    function onToolbarKeydown(e) {
      const { keyCode } = e

      if (keyCode < 35 || keyCode > 39 || keyCode === 38 || keyCode === 40) {
        return
      }

      // leave slot-provided content alone
      if (!e.target.closest?.('[data-tbi]')) return

      const controls = Array.prototype.filter.call(
        e.currentTarget.querySelectorAll('[data-tbi]'),
        el => !el.matches('[aria-disabled="true"], [disabled]')
      )

      const len = controls.length
      if (len === 0) return

      let control

      if (keyCode === 36) {
        // HOME key
        control = controls[0]
      } else if (keyCode === 35) {
        // END key
        control = controls[len - 1]
      } else {
        const dir =
          (keyCode === 37 /* ARROW LEFT */ ? -1 : 1) * ($q.lang.rtl ? -1 : 1)
        const index =
          (controls.indexOf(e.target.closest('[data-tbi]')) + dir + len) % len
        control = controls[index]
      }

      stopAndPrevent(e)
      control.focus({ preventScroll: true })
    }

    function onSelectionchange() {
      eVm.caret.save()
    }

    function setContent(v, restorePosition) {
      if (contentRef.value !== null) {
        if (restorePosition) {
          eVm.caret.savePosition()
        }

        const prop = `inner${isViewingSource.value ? 'Text' : 'HTML'}`
        contentRef.value[prop] = v

        if (restorePosition) {
          eVm.caret.restorePosition(contentRef.value[prop].length)
          refreshToolbar()
        }
      }
    }

    function runCmd(cmd, param, update = true) {
      focus()
      eVm.caret.restore()
      eVm.caret.apply(cmd, param, () => {
        focus()
        eVm.caret.save()
        if (update) {
          refreshToolbar()
        }
      })
    }

    function refreshToolbar() {
      if (refreshToolbarTimer !== null) {
        clearTimeout(refreshToolbarTimer)
      }

      refreshToolbarTimer = setTimeout(() => {
        refreshToolbarTimer = null
        editLinkUrl.value = null
        proxy.$forceUpdate()
      }, 1)
    }

    function focus() {
      addFocusFn(() => {
        contentRef.value?.focus({ preventScroll: true })
      })
    }

    function getContentEl() {
      return contentRef.value
    }

    onMounted(() => {
      eVm.caret = proxy.caret = new Caret(contentRef.value, eVm)
      setContent(props.modelValue)
      refreshToolbar()

      document.addEventListener('selectionchange', onSelectionchange)
    })

    onBeforeUnmount(() => {
      if (refreshToolbarTimer !== null) {
        clearTimeout(refreshToolbarTimer)
      }

      document.removeEventListener('selectionchange', onSelectionchange)
    })

    // expose public methods
    Object.assign(proxy, {
      runCmd,
      refreshToolbar,
      focus,
      getContentEl
    })

    return () => {
      let toolbars

      if (hasToolbar.value) {
        const bars = [
          h(
            'div',
            {
              key: 'qedt_top',
              class:
                'q-editor__toolbar row no-wrap scroll-x' +
                toolbarBackgroundClass.value,
              role: 'toolbar',
              'aria-label': $q.lang.editor.toolbar,
              onFocusin: onToolbarFocusin,
              onKeydown: onToolbarKeydown
            },
            getToolbar(eVm)
          )
        ]

        if (editLinkUrl.value !== null) {
          bars.push(
            h(
              'div',
              {
                key: 'qedt_btm',
                class:
                  'q-editor__toolbar row no-wrap items-center scroll-x' +
                  toolbarBackgroundClass.value
              },
              getLinkEditor(eVm)
            )
          )
        }

        toolbars = h(
          'div',
          {
            key: 'toolbar_ctainer',
            class: 'q-editor__toolbars-container'
          },
          bars
        )
      }

      return h(
        'div',
        {
          ref: rootRef,
          class: [classes.value, attrs.class],
          style: [{ height: inFullscreen.value ? '100%' : null }, attrs.style],
          onFocusin,
          onFocusout
        },
        [
          toolbars,

          h('div', {
            ref: contentRef,
            style: innerStyle.value,
            class: innerClass.value,
            contenteditable: editable.value,
            ...contentAttributes.value,
            ...(renderInitialContent ? { innerHTML: props.modelValue } : {}),
            ...splitAttrs.listeners.value,
            onInput,
            onKeydown,
            onClick,
            onBlur,
            onFocus,

            // clean saved scroll position
            onMousedown: onPointerStart,
            onTouchstartPassive: onPointerStart
          })
        ]
      )
    }
  }
})
