// Styles
import './VNumberInput.sass'

// Components
import { VBtn } from '@/components/VBtn'
import { VDefaultsProvider } from '@/components/VDefaultsProvider'
import { VDivider } from '@/components/VDivider'
import { makeVTextFieldProps, VTextField } from '@/components/VTextField/VTextField'

// Composables
import { formatNumber } from './format'
import { useHold } from './hold'
import { processGroupedInput, processPlainInput } from './typing'
import { useForm } from '@/composables/form'
import { forwardRefs } from '@/composables/forwardRefs'
import { useLocale } from '@/composables/locale'
import { useProxiedModel } from '@/composables/proxiedModel'

// Utilities
import { computed, nextTick, ref, shallowRef, toRef, watch } from 'vue'
import { clamp, consoleWarn, genericComponent, normalizeMinusSign, omit, propsFactory, useRender } from '@/util'

// Types
import type { PropType } from 'vue'
import type { VTextFieldSlots } from '@/components/VTextField/VTextField'

type ControlSlot = {
  props: Record<string, unknown>
}

type VNumberInputSlots = Omit<VTextFieldSlots, 'default'> & {
  increment: ControlSlot
  decrement: ControlSlot
}

type ControlVariant = 'default' | 'stacked' | 'split' | 'hidden'

const makeVNumberInputProps = propsFactory({
  controlVariant: {
    type: String as PropType<ControlVariant>,
    default: 'default',
  },
  inset: Boolean,
  hideInput: Boolean,
  modelValue: {
    type: Number as PropType<number | null>,
    default: null,
  },
  min: {
    type: Number,
    default: Number.MIN_SAFE_INTEGER,
  },
  max: {
    type: Number,
    default: Number.MAX_SAFE_INTEGER,
  },
  step: {
    type: Number,
    default: 1,
  },
  precision: {
    type: Number as PropType<number | null>,
    default: 0,
  },
  minFractionDigits: {
    type: Number as PropType<number | null>,
    default: null,
  },
  decimalSeparator: {
    type: String,
    validator: (v: any) => !v || v.length === 1,
  },
  grouping: {
    type: [Boolean, String] as PropType<'always' | 'auto' | 'min2' | boolean>,
    default: false,
  },
  groupSeparator: {
    type: String,
    validator: (v: any) => !v || (v.length === 1 && !/[0-9+-]/.test(v)),
  },

  ...omit(makeVTextFieldProps(), ['modelValue', 'validationValue']),
}, 'VNumberInput')

export const VNumberInput = genericComponent<VNumberInputSlots>()({
  name: 'VNumberInput',

  props: {
    ...makeVNumberInputProps(),
  },

  emits: {
    'update:focused': (val: boolean) => true,
    'update:modelValue': (val: number) => true,
  },

  setup (props, { slots }) {
    const vTextFieldRef = ref<VTextField>()

    const { holdStart, holdStop } = useHold({ toggleUpDown })
    const form = useForm(props)
    const controlsDisabled = computed(() => (
      form.isDisabled.value || form.isReadonly.value
    ))

    const isFocused = shallowRef(props.focused)

    const {
      current: locale,
      decimalSeparator: decimalSeparatorFromLocale,
      numericGroupSeparator: numericGroupSeparatorFromLocale,
    } = useLocale()

    const decimalSeparator = computed(() => props.decimalSeparator?.[0] || decimalSeparatorFromLocale.value)
    const groupSeparator = computed(() => {
      const separator = props.groupSeparator?.[0] || numericGroupSeparatorFromLocale.value
      if (separator !== decimalSeparator.value) return separator
      if (props.grouping) {
        consoleWarn(`decimalSeparator and groupSeparator are both "${separator}", groups will not be separated`)
      }
      return ''
    })
    const minusSign = computed(() => new Intl.NumberFormat(locale.value).formatToParts(-1).find(p => p.type === 'minusSign')?.value ?? '-')

    function toNumber (val: string | null | undefined) {
      return Number(normalizeMinusSign(val ?? '')
        .replaceAll(groupSeparator.value, '')
        .replace(decimalSeparator.value, '.')
        .replace(/[^0-9.-]/g, ''))
    }

    function correctPrecision (val: number, precision?: number | null, trim = true) {
      precision ??= isFocused.value && trim ? undefined : props.precision ?? undefined
      return formatNumber(val, {
        locale: locale.value,
        precision,
        minFractionDigits: props.minFractionDigits,
        useGrouping: props.grouping,
        decimalSeparator: decimalSeparator.value,
        groupSeparator: groupSeparator.value,
      })
    }

    const model = useProxiedModel(props, 'modelValue', null,
      val => val ?? null,
      val => val == null
        ? val ?? null
        : clamp(Number(val), props.min, props.max)
    )

    const _inputText = shallowRef<string | null>(null)
    const _lastParsedValue = shallowRef<number | null>(null)

    watch(model, val => {
      if (
        isFocused.value &&
          !controlsDisabled.value &&
          toNumber(_inputText.value) === val
      ) {
        // ignore external changes while typing
        // e.g. 5.01{backspace}2 » should result in 5.02
        //      but we emit '5' in and want to preserve '5.0'
      } else if (val == null) {
        _inputText.value = null
        _lastParsedValue.value = null
      } else if (!isNaN(val)) {
        _inputText.value = correctPrecision(val)
        _lastParsedValue.value = toNumber(_inputText.value)
      }
    }, { immediate: true })

    const inputText = computed<string | null>({
      get: () => _inputText.value,
      set (val) {
        if (val === null || val === '') {
          model.value = null
          _inputText.value = null
          _lastParsedValue.value = null
          return
        }
        const parsedValue = toNumber(val)
        if (!isNaN(parsedValue)) {
          _inputText.value = val
          _lastParsedValue.value = parsedValue

          if (parsedValue <= props.max && parsedValue >= props.min) {
            model.value = parsedValue
          }
        }
      },
    })

    const isOutOfRange = computed(() => {
      if (_lastParsedValue.value === null) return false
      const numberFromText = toNumber(_inputText.value)
      return numberFromText !== clamp(numberFromText, props.min, props.max)
    })

    const canIncrease = computed(() => {
      if (controlsDisabled.value) return false
      if (model.value == null) return true
      return model.value + props.step <= props.max
    })
    const canDecrease = computed(() => {
      if (controlsDisabled.value) return false
      if (model.value == null) return true
      return model.value - props.step >= props.min
    })

    const controlVariant = computed(() => {
      return props.hideInput ? 'stacked' : props.controlVariant
    })

    const incrementIcon = toRef(() => controlVariant.value === 'split' ? '$plus' : '$collapse')
    const decrementIcon = toRef(() => controlVariant.value === 'split' ? '$minus' : '$expand')
    const controlNodeSize = toRef(() => controlVariant.value === 'split' ? 'default' : 'small')
    const controlNodeDefaultHeight = toRef(() => controlVariant.value === 'stacked' ? 'auto' : '100%')

    const incrementSlotProps = {
      props: {
        onClick: onControlClick,
        onPointerup: onControlMouseup,
        onPointerdown: onUpControlMousedown,
        onPointercancel: onControlMouseup,
      },
    }
    const decrementSlotProps = {
      props: {
        onClick: onControlClick,
        onPointerup: onControlMouseup,
        onPointerdown: onDownControlMousedown,
        onPointercancel: onControlMouseup,
      },
    }

    watch(
      () => [locale.value, decimalSeparator.value, groupSeparator.value, props.precision, props.minFractionDigits],
      () => formatInputValue()
    )

    function inferPrecision (value: number | string | null) {
      if (value == null) return 0
      const str = value.toString()
      const idx = str.indexOf('.') + 1
      return idx ? str.length - idx : 0
    }

    function emitChange () {
      vTextFieldRef.value?.controlRef?.dispatchEvent(new Event('change', { bubbles: true }))
    }

    function toggleUpDown (increment = true) {
      if (controlsDisabled.value) return
      if (increment ? !canIncrease.value : !canDecrease.value) return
      if (model.value == null) {
        inputText.value = correctPrecision(clamp(0, props.min, props.max))
        emitChange()
        return
      }
      const inferredPrecision = Math.max(inferPrecision(toNumber(inputText.value)), inferPrecision(props.step))
      if (increment && canIncrease.value) {
        inputText.value = correctPrecision(model.value + props.step, inferredPrecision)
        emitChange()
      } else if (!increment && canDecrease.value) {
        inputText.value = correctPrecision(model.value - props.step, inferredPrecision)
        emitChange()
      }
    }

    function onBeforeinput (e: InputEvent) {
      if (controlsDisabled.value) return
      const inputElement = e.target as HTMLInputElement

      const result = props.grouping
        ? processGroupedInput(
          e.inputType,
          e.data,
          inputElement.value ?? '',
          inputElement.selectionStart ?? 0,
          inputElement.selectionEnd ?? 0,
          {
            groupSeparator: groupSeparator.value,
            decimalSeparator: decimalSeparator.value,
            precision: props.precision,
            grouping: props.grouping,
            locale: locale.value,
            minusSign: minusSign.value,
          }
        )
        : processPlainInput(
          e.data,
          inputElement.value ?? '',
          inputElement.selectionStart ?? 0,
          inputElement.selectionEnd ?? 0,
          {
            decimalSeparator: decimalSeparator.value,
            precision: props.precision,
            minusSign: minusSign.value,
          }
        )

      if (result === null) return

      e.preventDefault()

      inputElement.value = result.text
      inputElement.setSelectionRange(result.cursor, result.cursor)

      nextTick(() => inputText.value = result.text)
    }

    async function onKeydown (e: KeyboardEvent) {
      if (
        ['Enter', 'ArrowLeft', 'ArrowRight', 'Backspace', 'Delete', 'Tab'].includes(e.key) ||
        e.ctrlKey
      ) return

      if (['ArrowDown', 'ArrowUp'].includes(e.key)) {
        e.preventDefault()
        e.stopPropagation()
        clampModel()
        // _model is controlled, so need to wait until props['modelValue'] is updated
        await nextTick()
        if (e.key === 'ArrowDown') {
          toggleUpDown(false)
        } else {
          toggleUpDown()
        }
      }
    }

    function onControlClick (e: MouseEvent) {
      e.stopPropagation()
    }

    function onControlMouseup (e: PointerEvent) {
      const el = e.currentTarget as HTMLElement
      el?.releasePointerCapture(e.pointerId)
      e.preventDefault()
      holdStop()
    }

    function onUpControlMousedown (e: PointerEvent) {
      const el = e.currentTarget as HTMLElement
      el?.setPointerCapture(e.pointerId)
      e.preventDefault()
      e.stopPropagation()
      holdStart('up')
    }

    function onDownControlMousedown (e: PointerEvent) {
      const el = e.currentTarget as HTMLElement
      el?.setPointerCapture(e.pointerId)
      e.preventDefault()
      e.stopPropagation()
      holdStart('down')
    }

    function clampModel () {
      if (controlsDisabled.value) return
      if (!vTextFieldRef.value) return
      const actualText = vTextFieldRef.value.value
      const parsedValue = toNumber(actualText)
      if (actualText && !isNaN(parsedValue)) {
        inputText.value = correctPrecision(clamp(parsedValue, props.min, props.max))
      } else {
        inputText.value = null
      }
    }

    function formatInputValue () {
      if (controlsDisabled.value) return
      inputText.value = model.value !== null && !isNaN(model.value)
        ? correctPrecision(model.value, props.precision, false)
        : null
    }

    function trimDecimalZeros () {
      if (controlsDisabled.value) return
      if (model.value === null || isNaN(model.value)) {
        inputText.value = null
        return
      }
      inputText.value = correctPrecision(model.value)
    }

    function onFocus () {
      trimDecimalZeros()
    }

    function onBlur () {
      clampModel()
    }

    useRender(() => {
      const { modelValue: _, type, ...textFieldProps } = VTextField.filterProps(props)

      function incrementControlNode () {
        return !slots.increment ? (
          <VBtn
            aria-hidden="true"
            data-testid="increment"
            disabled={ !canIncrease.value }
            height={ controlNodeDefaultHeight.value }
            icon={ incrementIcon.value }
            key="increment-btn"
            onClick={ onControlClick }
            onPointerdown={ onUpControlMousedown }
            onPointerup={ onControlMouseup }
            onPointercancel={ onControlMouseup }
            size={ controlNodeSize.value }
            variant="text"
            tabindex="-1"
          />
        ) : (
          <VDefaultsProvider
            key="increment-defaults"
            defaults={{
              VBtn: {
                disabled: !canIncrease.value,
                height: controlNodeDefaultHeight.value,
                size: controlNodeSize.value,
                icon: incrementIcon.value,
                variant: 'text',
              },
            }}
          >
            { slots.increment(incrementSlotProps) }
          </VDefaultsProvider>
        )
      }

      function decrementControlNode () {
        return !slots.decrement ? (
          <VBtn
            aria-hidden="true"
            data-testid="decrement"
            disabled={ !canDecrease.value }
            height={ controlNodeDefaultHeight.value }
            icon={ decrementIcon.value }
            key="decrement-btn"
            onClick={ onControlClick }
            onPointerdown={ onDownControlMousedown }
            onPointerup={ onControlMouseup }
            onPointercancel={ onControlMouseup }
            size={ controlNodeSize.value }
            variant="text"
            tabindex="-1"
          />
        ) : (
          <VDefaultsProvider
            key="decrement-defaults"
            defaults={{
              VBtn: {
                disabled: !canDecrease.value,
                height: controlNodeDefaultHeight.value,
                size: controlNodeSize.value,
                icon: decrementIcon.value,
                variant: 'text',
              },
            }}
          >
            { slots.decrement(decrementSlotProps) }
          </VDefaultsProvider>
        )
      }

      function controlNode () {
        return (
          <div class="v-number-input__control">
            { decrementControlNode() }

            <VDivider
              vertical={ controlVariant.value !== 'stacked' }
            />

            { incrementControlNode() }
          </div>
        )
      }

      function dividerNode () {
        return !props.hideInput && !props.inset ? <VDivider vertical /> : undefined
      }

      const appendInnerControl =
        controlVariant.value === 'split'
          ? (
            <div class="v-number-input__control">
              <VDivider vertical />

              { incrementControlNode() }
            </div>
          ) : (props.reverse || controlVariant.value === 'hidden'
            ? undefined
            : <>{ dividerNode() }{ controlNode() }</>)

      const hasAppendInner = slots['append-inner'] || appendInnerControl

      const prependInnerControl =
        controlVariant.value === 'split'
          ? (
            <div class="v-number-input__control">
              { decrementControlNode() }

              <VDivider vertical />
            </div>
          ) : (props.reverse && controlVariant.value !== 'hidden'
            ? <>{ controlNode() }{ dividerNode() }</>
            : undefined)

      const hasPrependInner = slots['prepend-inner'] || prependInnerControl

      return (
        <VTextField
          ref={ vTextFieldRef }
          { ...textFieldProps }
          v-model={ inputText.value }
          v-model:focused={ isFocused.value }
          validationValue={ model.value }
          error={ props.error || isOutOfRange.value || undefined }
          onBeforeinput={ onBeforeinput }
          onFocus={ onFocus }
          onBlur={ onBlur }
          onKeydown={ onKeydown }
          class={[
            'v-number-input',
            {
              'v-number-input--default': controlVariant.value === 'default',
              'v-number-input--hide-input': props.hideInput,
              'v-number-input--inset': props.inset,
              'v-number-input--reverse': props.reverse,
              'v-number-input--split': controlVariant.value === 'split',
              'v-number-input--stacked': controlVariant.value === 'stacked',
            },
            props.class,
          ]}
          style={ props.style }
          inputmode="decimal"
        >
          {{
            ...slots,
            'append-inner': hasAppendInner ? (...args) => (
              <>
                { slots['append-inner']?.(...args) }
                { appendInnerControl }
              </>
            ) : undefined,
            'prepend-inner': hasPrependInner ? (...args) => (
              <>
                { prependInnerControl }
                { slots['prepend-inner']?.(...args) }
              </>
            ) : undefined,
          }}
        </VTextField>
      )
    })

    return forwardRefs({}, vTextFieldRef)
  },
})

export type VNumberInput = InstanceType<typeof VNumberInput>
