import { computed, h, ref, watch } from 'vue'

import useQuasar from '../../composables/use-quasar/use-quasar.js'
import useSlider, {
  keyCodes,
  useSliderEmits,
  useSliderProps
} from '../slider/use-slider.js'

import { createComponent } from '../../utils/private.create/create.js'
import { stopAndPrevent } from '../../utils/event/event.js'
import { between } from '../../utils/format/format.js'

const dragType = {
  MIN: 0,
  RANGE: 1,
  MAX: 2
}

const emptyModel = { min: null, max: null }

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

  props: {
    ...useSliderProps,

    modelValue: {
      type: Object,
      default: () => ({ min: null, max: null }),
      validator: v => 'min' in v && 'max' in v
    },

    minRange: Number,
    maxRange: Number,

    dragRange: Boolean,
    dragOnlyRange: Boolean,

    leftLabelColor: String,
    leftLabelTextColor: String,
    rightLabelColor: String,
    rightLabelTextColor: String,

    leftLabelValue: [String, Number],
    rightLabelValue: [String, Number],

    leftThumbColor: String,
    rightThumbColor: String,

    leftThumbAriaLabel: String,
    rightThumbAriaLabel: String
  },

  emits: useSliderEmits,

  setup(props, { emit }) {
    const $q = useQuasar()

    // the model can also be null, which means the same thing
    // as having no value on either end of the range
    const modelProp = computed(() => props.modelValue || emptyModel)

    const { state, methods } = useSlider({
      updateValue,
      updatePosition,
      getDragging,
      formAttrs: () => ({
        type: 'hidden',
        name: props.name,
        value: `${modelProp.value.min}|${modelProp.value.max}`
      })
    })

    const rootRef = ref(null)
    const curMinRatio = ref(0)
    const curMaxRatio = ref(0)
    const model = ref({ min: 0, max: 0 })

    // "change" reports what an interaction actually changed: the values are
    // snapshotted when the interaction starts (pointer via getDragging,
    // keyboard on its first keydown) and compared at commit time, so an
    // interaction that ends where it started stays silent
    let changeBaseline = null

    // dedups re-emissions towards a parent that does not sync the model
    // prop back; see QSlider's emittedValue
    let emittedValue = null

    // effective selection-width limits, capped to what the inner track
    // can hold; minRange wins over a smaller maxRange
    function getWidthLimits() {
      const len = state.innerMax.value - state.innerMin.value
      const min = Number.isFinite(props.minRange)
        ? between(props.minRange, 0, len)
        : 0

      return {
        min,
        max: Number.isFinite(props.maxRange)
          ? between(props.maxRange, min, len)
          : len
      }
    }

    // the interval one thumb can move in, given the other thumb's value
    // and the width limits; expects an already normalized other value
    function getThumbBounds(which, otherValue, limits = getWidthLimits()) {
      return which === 'min'
        ? {
            min: Math.max(state.innerMin.value, otherValue - limits.max),
            max: otherValue - limits.min
          }
        : {
            min: otherValue + limits.min,
            max: Math.min(state.innerMax.value, otherValue + limits.max)
          }
    }

    function normalizeModel() {
      emittedValue = null

      let min =
        modelProp.value.min === null
          ? state.innerMin.value
          : between(
              modelProp.value.min,
              state.innerMin.value,
              state.innerMax.value
            )

      let max =
        modelProp.value.max === null
          ? state.innerMax.value
          : between(
              modelProp.value.max,
              state.innerMin.value,
              state.innerMax.value
            )

      if (Number.isFinite(props.minRange) || Number.isFinite(props.maxRange)) {
        // like inner-min/inner-max, the width limits coerce the displayed
        // model (without emitting); min acts as the anchor
        const limits = getWidthLimits()

        max = between(max, min + limits.min, min + limits.max)

        if (max > state.innerMax.value) {
          max = state.innerMax.value
          min = max - limits.min
        }
      }

      model.value.min = min
      model.value.max = max
    }

    watch(
      () =>
        `${modelProp.value.min}|${modelProp.value.max}|${state.innerMin.value}|${state.innerMax.value}|${props.minRange}|${props.maxRange}`,
      normalizeModel
    )

    normalizeModel()

    const modelMinRatio = computed(() =>
      methods.convertModelToRatio(model.value.min)
    )
    const modelMaxRatio = computed(() =>
      methods.convertModelToRatio(model.value.max)
    )

    const ratioMin = computed(() =>
      state.active.value ? curMinRatio.value : modelMinRatio.value
    )
    const ratioMax = computed(() =>
      state.active.value ? curMaxRatio.value : modelMaxRatio.value
    )

    const selectionBarStyle = computed(() => {
      const acc = {
        [state.positionProp.value]: `${100 * ratioMin.value}%`,
        [state.sizeProp.value]: `${100 * (ratioMax.value - ratioMin.value)}%`
      }
      if (props.selectionImg !== void 0) {
        acc.backgroundImage = `url(${props.selectionImg}) !important`
      }
      return acc
    })

    function getEditableAriaState() {
      return props.disable
        ? { 'aria-disabled': 'true' }
        : props.readonly
          ? { 'aria-readonly': 'true' }
          : {}
    }

    // one WAI-ARIA slider per thumb (unless drag-only-range, where the
    // track container is the single focusable acting on the whole window)
    function getThumbAriaAttrs(which) {
      if (props.dragOnlyRange) return {}

      const isMin = which === 'min',
        labelValue = props[isMin ? 'leftLabelValue' : 'rightLabelValue'],
        // each thumb is clamped against the other one and the width
        // limits (see onKeydown), so its effective limits are what
        // gets exposed
        bounds = getThumbBounds(which, model.value[isMin ? 'max' : 'min'])

      return {
        role: 'slider',
        'aria-label':
          props[isMin ? 'leftThumbAriaLabel' : 'rightThumbAriaLabel'] ||
          $q.lang.label[isMin ? 'minimum' : 'maximum'],
        'aria-orientation': state.orientation.value,
        'aria-valuemin': bounds.min,
        'aria-valuemax': bounds.max,
        'aria-valuenow': model.value[which],
        // a null side still reports a number (the limit its thumb sits on),
        // so say what that number means: nothing has been picked yet
        ...(labelValue !== void 0
          ? { 'aria-valuetext': labelValue }
          : modelProp.value[which] === null
            ? { 'aria-valuetext': $q.lang.label.noValue }
            : {}),
        ...getEditableAriaState()
      }
    }

    const minThumbAriaAttrs = computed(() => getThumbAriaAttrs('min'))
    const maxThumbAriaAttrs = computed(() => getThumbAriaAttrs('max'))

    const rootAttrs = computed(() => ({
      role: 'group',
      'data-step': props.step,
      // only aria-disabled is allowed on a group; aria-readonly belongs to
      // the thumbs, which carry the slider role
      ...(props.disable ? { 'aria-disabled': 'true' } : {})
    }))

    const trackContainerAriaAttrs = computed(() => {
      if (!props.dragRange && !props.dragOnlyRange) {
        return {}
      }

      const min = model.value.min,
        max = model.value.max,
        minLabel = props.leftLabelValue !== void 0 ? props.leftLabelValue : min,
        maxLabel =
          props.rightLabelValue !== void 0 ? props.rightLabelValue : max

      return {
        role: 'slider',
        'aria-label': $q.lang.label.range,
        'aria-orientation': state.orientation.value,
        'aria-valuemin': state.innerMin.value,
        'aria-valuemax': state.innerMax.value,
        'aria-valuenow': min,
        'aria-valuetext': `${minLabel}–${maxLabel}`,
        ...getEditableAriaState()
      }
    })

    const trackContainerEvents = computed(() => {
      if (!state.editable.value) return {}

      // same wiring on every device; see QSlider's trackContainerEvents
      const evt = {
        onClick: methods.onClick,
        onMousedown: methods.onActivate
      }

      if (props.dragRange || props.dragOnlyRange) {
        Object.assign(evt, {
          onFocus: () => {
            state.focus.value = 'both'
          },
          onBlur: methods.onBlur,
          onKeydown,
          onKeyup: methods.onKeyup
        })
      }

      return evt
    })

    const trackContainerData = computed(() => ({
      ...trackContainerAriaAttrs.value,
      ...trackContainerEvents.value
    }))

    function getEvents(side) {
      return state.editable.value && !props.dragOnlyRange
        ? {
            onFocus: () => {
              state.focus.value = side
            },
            onBlur: methods.onBlur,
            onKeydown,
            onKeyup: methods.onKeyup
          }
        : {}
    }

    const thumbTabindex = computed(() =>
      props.dragOnlyRange ? null : state.tabindex.value
    )
    const trackContainerTabindex = computed(() =>
      props.dragRange || props.dragOnlyRange ? state.tabindex.value : null
    )

    const minThumbRef = ref(null)
    const minEvents = computed(() => getEvents('min'))
    const getMinThumb = methods.getThumbRenderFn({
      focusValue: 'min',
      getNodeData: () => ({
        ref: minThumbRef,
        key: 'tmin',
        ...minEvents.value,
        tabindex: thumbTabindex.value,
        ...minThumbAriaAttrs.value
      }),
      ratio: ratioMin,
      label: computed(() =>
        props.leftLabelValue !== void 0 ? props.leftLabelValue : model.value.min
      ),
      thumbColor: computed(
        () => props.leftThumbColor || props.thumbColor || props.color
      ),
      labelColor: computed(() => props.leftLabelColor || props.labelColor),
      labelTextColor: computed(
        () => props.leftLabelTextColor || props.labelTextColor
      )
    })

    const maxEvents = computed(() => getEvents('max'))
    const getMaxThumb = methods.getThumbRenderFn({
      injectFormInput: false,
      focusValue: 'max',
      getNodeData: () => ({
        ...maxEvents.value,
        key: 'tmax',
        tabindex: thumbTabindex.value,
        ...maxThumbAriaAttrs.value
      }),
      ratio: ratioMax,
      label: computed(() =>
        props.rightLabelValue !== void 0
          ? props.rightLabelValue
          : model.value.max
      ),
      thumbColor: computed(
        () => props.rightThumbColor || props.thumbColor || props.color
      ),
      labelColor: computed(() => props.rightLabelColor || props.labelColor),
      labelTextColor: computed(
        () => props.rightLabelTextColor || props.labelTextColor
      )
    })

    function updateValue(change) {
      if (
        (model.value.min !== modelProp.value.min ||
          model.value.max !== modelProp.value.max) &&
        (emittedValue === null ||
          model.value.min !== emittedValue.min ||
          model.value.max !== emittedValue.max)
      ) {
        emittedValue = { min: model.value.min, max: model.value.max }
        emit('update:modelValue', { ...model.value })
      }

      if (change) {
        if (
          changeBaseline !== null &&
          (changeBaseline.min !== model.value.min ||
            changeBaseline.max !== model.value.max)
        ) {
          emit('change', { ...model.value })
        }
        changeBaseline = null
      }
    }

    function getDragging(event) {
      changeBaseline = { ...model.value }

      const { left, top, width, height } =
          rootRef.value.getBoundingClientRect(),
        sensitivity = props.dragOnlyRange
          ? 0
          : props.vertical
            ? minThumbRef.value.offsetHeight / (2 * height)
            : minThumbRef.value.offsetWidth / (2 * width)

      const dragging = {
        left,
        top,
        width,
        height,
        valueMin: model.value.min,
        valueMax: model.value.max,
        ratioMin: modelMinRatio.value,
        ratioMax: modelMaxRatio.value
      }

      const ratio = methods.getDraggingRatio(event, dragging)

      if (!props.dragOnlyRange && ratio < dragging.ratioMin + sensitivity) {
        dragging.type = dragType.MIN
      } else if (
        props.dragOnlyRange ||
        ratio < dragging.ratioMax - sensitivity
      ) {
        if (props.dragRange || props.dragOnlyRange) {
          dragging.type = dragType.RANGE
          Object.assign(dragging, {
            offsetRatio: ratio,
            offsetModel: methods.convertRatioToModel(ratio),
            rangeValue: dragging.valueMax - dragging.valueMin,
            rangeRatio: dragging.ratioMax - dragging.ratioMin
          })
        } else {
          dragging.type =
            dragging.ratioMax - ratio < ratio - dragging.ratioMin
              ? dragType.MAX
              : dragType.MIN
        }
      } else {
        dragging.type = dragType.MAX
      }

      return dragging
    }

    function updatePosition(event, dragging = state.dragging.value) {
      let pos
      const ratio = methods.getDraggingRatio(event, dragging)
      const localModel = methods.convertRatioToModel(ratio)
      const limits = getWidthLimits()

      // clamps a dragged thumb (value + visual ratio) to its bounds
      function getThumbPos(which, otherValue) {
        const bounds = getThumbBounds(which, otherValue, limits)

        return {
          value: between(localModel, bounds.min, bounds.max),
          ratio: between(
            ratio,
            methods.convertModelToRatio(bounds.min),
            methods.convertModelToRatio(bounds.max)
          )
        }
      }

      switch (dragging.type) {
        case dragType.MIN: {
          // a minimum width blocks thumb crossover
          // (the widths in between would be forbidden)
          if (limits.min > 0 || ratio <= dragging.ratioMax) {
            const thumb = getThumbPos('min', dragging.valueMax)
            pos = {
              minR: thumb.ratio,
              maxR: dragging.ratioMax,
              min: thumb.value,
              max: dragging.valueMax
            }
            state.focus.value = 'min'
          } else {
            const thumb = getThumbPos('max', dragging.valueMax)
            pos = {
              minR: dragging.ratioMax,
              maxR: thumb.ratio,
              min: dragging.valueMax,
              max: thumb.value
            }
            state.focus.value = 'max'
          }
          break
        }

        case dragType.MAX: {
          if (limits.min > 0 || ratio >= dragging.ratioMin) {
            const thumb = getThumbPos('max', dragging.valueMin)
            pos = {
              minR: dragging.ratioMin,
              maxR: thumb.ratio,
              min: dragging.valueMin,
              max: thumb.value
            }
            state.focus.value = 'max'
          } else {
            const thumb = getThumbPos('min', dragging.valueMin)
            pos = {
              minR: thumb.ratio,
              maxR: dragging.ratioMin,
              min: thumb.value,
              max: dragging.valueMin
            }
            state.focus.value = 'min'
          }
          break
        }

        case dragType.RANGE: {
          const ratioDelta = ratio - dragging.offsetRatio,
            minR = between(
              dragging.ratioMin + ratioDelta,
              state.innerMinRatio.value,
              state.innerMaxRatio.value - dragging.rangeRatio
            ),
            modelDelta = localModel - dragging.offsetModel,
            min = between(
              dragging.valueMin + modelDelta,
              state.innerMin.value,
              state.innerMax.value - dragging.rangeValue
            )

          pos = {
            minR,
            maxR: minR + dragging.rangeRatio,
            min: state.roundValueFn.value(min),
            max: state.roundValueFn.value(min + dragging.rangeValue)
          }

          state.focus.value = 'both'
          break
        }
      }

      model.value = { min: pos.min, max: pos.max }

      if (!props.snap || props.step === 0) {
        curMinRatio.value = pos.minR
        curMaxRatio.value = pos.maxR
      } else {
        curMinRatio.value = methods.convertModelToRatio(model.value.min)
        curMaxRatio.value = methods.convertModelToRatio(model.value.max)
      }
    }

    function onKeydown(evt) {
      if (!keyCodes.includes(evt.keyCode)) return

      stopAndPrevent(evt)

      if (changeBaseline === null) {
        changeBaseline = { ...model.value }
      }

      // HOME/END jump straight to the focused thumb's limits
      // (never direction-reversed)
      if (evt.keyCode === 36 || evt.keyCode === 35) {
        const toStart = evt.keyCode === 36

        if (state.focus.value === 'both') {
          // the whole window moves to the edge, preserving its width
          const interval = model.value.max - model.value.min
          const min = toStart
            ? state.innerMin.value
            : state.innerMax.value - interval

          model.value = {
            min,
            max: state.roundValueFn.value(min + interval)
          }
        } else if (!state.focus.value) {
          return
        } else if (state.focus.value === 'min') {
          const bounds = getThumbBounds('min', model.value.max)
          model.value = {
            ...model.value,
            min: toStart ? bounds.min : bounds.max
          }
        } else {
          const bounds = getThumbBounds('max', model.value.min)
          model.value = {
            ...model.value,
            max: toStart ? bounds.min : bounds.max
          }
        }

        updateValue()
        return
      }

      const stepVal =
          ([34, 33].includes(evt.keyCode) ? 10 : 1) * state.keyStep.value,
        offset =
          ([34, 37, 40].includes(evt.keyCode) ? -1 : 1) *
          (state.isReversed.value ? -1 : 1) *
          (props.vertical ? -1 : 1) *
          stepVal

      if (state.focus.value === 'both') {
        const interval = model.value.max - model.value.min
        const min = between(
          state.roundValueFn.value(model.value.min + offset),
          state.innerMin.value,
          state.innerMax.value - interval
        )

        model.value = {
          min,
          max: state.roundValueFn.value(min + interval)
        }
      } else if (!state.focus.value) {
        return
      } else {
        const which = state.focus.value
        const bounds = getThumbBounds(
          which,
          model.value[which === 'min' ? 'max' : 'min']
        )

        model.value = {
          ...model.value,
          [which]: between(
            state.roundValueFn.value(model.value[which] + offset),
            bounds.min,
            bounds.max
          )
        }
      }

      updateValue()
    }

    return () => {
      const content = methods.getContent(
        selectionBarStyle,
        trackContainerTabindex,
        trackContainerData,
        node => {
          node.push(getMinThumb(), getMaxThumb())
        }
      )

      return h(
        'div',
        {
          ref: rootRef,
          class:
            'q-range ' +
            state.classes.value +
            (modelProp.value.min === null || modelProp.value.max === null
              ? ' q-slider--no-value'
              : ''),
          ...rootAttrs.value
        },
        content
      )
    }
  }
})
