<template>
  <div>
    <div
      ref="root"
      :class="mergedGroupClass"
    >
      <input
        :id="inputId"
        :name="name"
        autocomplete="off"
        :class="mergedInputClass"
        :required="required"
        :disabled="disabled"
        :readonly="readonly"
        :value="inputValue"
        :aria-invalid="!!error"
        :aria-describedby="describedBy"
        :aria-expanded="isOpen"
        :aria-controls="listboxId"
        :aria-activedescendant="activeOptionId"
        role="combobox"
        v-bind="attrs"
        placeholder="_"
        type="text"
        @input="onInput"
        @focus="onFocus"
        @click="onInputClick"
        @keydown="onKeydown"
      >

      <label
        v-if="label"
        :for="inputId"
        :class="mergedLabelClass"
      >
        {{ label }}
      </label>

      <IconifyIcon
        v-if="iconName && iconPosition === 'left'"
        :icon="iconName"
        :width="iconSize"
        :height="iconSize"
        data-test="icon-left"
        :class="[iconStateClass, 'pointer-events-none absolute left-[10px] top-1/2 -translate-y-1/2']"
      />

      <div class="pointer-events-none absolute right-3 top-1/2 flex -translate-y-1/2 items-center gap-1">
        <IconifyIcon
          v-if="iconName && iconPosition === 'right'"
          :icon="iconName"
          :width="iconSize"
          :height="iconSize"
          data-test="icon-right"
          :class="[iconStateClass]"
        />
        <IconifyIcon
          v-if="loading"
          icon="mdi:loading"
          :width="20"
          :height="20"
          data-test="loading-icon"
          class="animate-spin text-m-primary"
        />
        <IconifyIcon
          v-else
          icon="mdi:chevron-down"
          :width="20"
          :height="20"
          data-test="chevron"
          class="transition-transform duration-300"
          :class="[
            isOpen ? 'rotate-180' : 'rotate-0',
            chevronColorClass,
          ]"
        />
      </div>

      <ul
        v-if="isOpen"
        :id="listboxId"
        ref="listRef"
        data-test="dropdown"
        role="listbox"
        :aria-labelledby="inputId"
        class="absolute left-0 right-0 top-[calc(100%-4px)] z-20 max-h-60 w-full overflow-auto rounded-b-md border border-t-0 bg-white"
        :class="[
          hasError
            ? 'border-m-danger select-scrollbar-error'
            : hasSuccess
              ? 'border-m-success select-scrollbar-success'
              : 'border-m-primary select-scrollbar-primary',
        ]"
      >
        <li
          v-if="loading"
          class="px-3 py-2 text-m-muted"
          data-test="loading-text"
        >
          {{ loadingText }}
        </li>
        <li
          v-else-if="showMinSearch"
          class="px-3 py-2 text-m-muted"
          data-test="min-search-text"
        >
          {{ minSearchText }}
        </li>
        <li
          v-else-if="filteredOptions.length === 0"
          class="px-3 py-2 text-m-muted"
          data-test="no-results-text"
        >
          {{ noResultsText }}
        </li>
        <template v-else>
          <li
            v-for="(opt, index) in filteredOptions"
            :id="optionId(index)"
            :key="String(opt.value)"
            data-test="option"
            role="option"
            :aria-selected="opt.value === modelValue"
            class="cursor-pointer px-3 py-2 text-black"
            :class="[
              index === activeIndex ? 'bg-m-muted/10' : '',
              opt.value === modelValue ? 'bg-m-muted/10 font-semibold' : '',
            ]"
            @mouseenter="activeIndex = index"
            @mousedown.prevent
            @click="onSelect(opt)"
          >
            {{ opt.label || '\u00A0' }}
          </li>
        </template>
      </ul>
    </div>
    <p
      :id="`${inputId}-describedby`"
      :class="[
        hasError ? 'text-m-danger' : hasSuccess ? 'text-m-success' : 'text-m-muted',
        'mt-1 ml-[2px] text-xs min-h-[1rem]',
      ]"
    >
      {{ hint || error || success }}
    </p>
  </div>
</template>

<script setup lang="ts">
import {computed, onBeforeUnmount, onMounted, ref, useAttrs, useId, watch} from 'vue'
import {Icon as IconifyIcon} from '@iconify/vue'
import {twMerge} from 'tailwind-merge'

defineOptions({name: 'MalioInputAutocomplete', inheritAttrs: false})

type Option = {
  label: string
  value: string | number
}

const props = withDefaults(
  defineProps<{
    id?: string
    label?: string
    name?: string
    modelValue?: string | number | null
    inputClass?: string
    labelClass?: string
    groupClass?: string
    required?: boolean
    disabled?: boolean
    readonly?: boolean
    hint?: string
    error?: string
    success?: string
    options?: Option[]
    loading?: boolean
    debounce?: number
    minSearchLength?: number
    allowCreate?: boolean
    localFilter?: boolean
    iconName?: string
    iconPosition?: 'left' | 'right'
    iconSize?: string | number
    iconColor?: string
    noResultsText?: string
    loadingText?: string
    minSearchText?: string
  }>(),
  {
    id: '',
    name: '',
    modelValue: undefined,
    inputClass: '',
    labelClass: '',
    groupClass: '',
    label: '',
    required: false,
    disabled: false,
    readonly: false,
    hint: '',
    error: '',
    success: '',
    options: () => [],
    loading: false,
    debounce: 300,
    minSearchLength: 0,
    allowCreate: false,
    localFilter: false,
    iconName: '',
    iconPosition: 'left',
    iconSize: 24,
    iconColor: 'text-m-muted',
    noResultsText: 'Aucun résultat',
    loadingText: 'Chargement…',
    minSearchText: 'Tapez pour rechercher',
  },
)

const emit = defineEmits<{
  (e: 'update:modelValue', value: string | number | null): void
  (e: 'search' | 'create', value: string): void
  (e: 'select', option: Option | null): void
}>()

const attrs = useAttrs()
const generatedId = useId()
const root = ref<HTMLElement | null>(null)
const listRef = ref<HTMLElement | null>(null)
const inputValue = ref<string>('')
const isFocused = ref(false)
const isOpen = ref(false)
const activeIndex = ref(-1)
let debounceTimer: ReturnType<typeof setTimeout> | null = null

const inputId = computed(() => props.id?.toString() || `malio-input-autocomplete-${generatedId}`)
const listboxId = computed(() => `${inputId.value}-listbox`)

const selectedOption = computed(() =>
  props.options.find(o => o.value === props.modelValue) ?? null,
)

const hasSelection = computed(() =>
  props.modelValue !== null && props.modelValue !== undefined && props.modelValue !== '',
)

const hasError = computed(() => !!props.error)
const hasSuccess = computed(() => !!props.success && !hasError.value)
const isFilled = computed(() => inputValue.value.trim().length > 0 || hasSelection.value)
const shouldFloatLabel = computed(() => isFocused.value || inputValue.value.length > 0)

const showMinSearch = computed(() =>
  props.minSearchLength > 0 && inputValue.value.length < props.minSearchLength,
)

const filteredOptions = computed(() => {
  if (!props.localFilter) return props.options
  const query = inputValue.value.trim().toLowerCase()
  if (query === '') return props.options
  return props.options.filter(opt =>
    opt.label.toLowerCase().includes(query),
  )
})

const optionId = (index: number) => `${inputId.value}-option-${index}`
const activeOptionId = computed(() =>
  activeIndex.value >= 0 && filteredOptions.value[activeIndex.value]
    ? optionId(activeIndex.value)
    : undefined,
)

const describedBy = computed(() =>
  (props.hint || hasError.value || hasSuccess.value) ? `${inputId.value}-describedby` : undefined,
)

watch(
  [() => props.modelValue, () => props.options],
  () => {
    if (isFocused.value) return
    if (selectedOption.value) {
      inputValue.value = selectedOption.value.label
    } else if (props.allowCreate && typeof props.modelValue === 'string' && props.modelValue !== '') {
      inputValue.value = props.modelValue
    } else if (!hasSelection.value) {
      inputValue.value = ''
    }
  },
  {immediate: true},
)

const mergedGroupClass = computed(() =>
  twMerge('relative flex h-12 w-full items-center', props.groupClass),
)

const iconInputPaddingClass = computed(() => {
  const parts: string[] = []
  if (props.iconName && props.iconPosition === 'left') parts.push('!pl-11')

  const hasCustomRight = !!props.iconName && props.iconPosition === 'right'
  if (hasCustomRight) parts.push('!pr-16')
  else parts.push('!pr-10')

  return parts.join(' ')
})

const labelPositionClass = computed(() =>
  props.iconName && props.iconPosition === 'left' ? 'left-11' : 'left-3',
)

const mergedInputClass = computed(() =>
  twMerge(
    'floating-input grow-height peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent text-lg rounded-md',
    isFilled.value ? 'border-black' : 'border-m-muted',
    props.disabled
      ? 'cursor-not-allowed text-black/60 [&:not(:placeholder-shown)]:border-m-muted border-m-muted'
      : 'cursor-text',
    hasError.value
      ? 'border-m-danger focus:border-m-danger [&:not(:placeholder-shown)]:border-m-danger'
      : hasSuccess.value
        ? 'border-m-success focus:border-m-success [&:not(:placeholder-shown)]:border-m-success'
        : 'focus:border-m-primary',
    isOpen.value ? '!rounded-b-none !border-b-transparent' : '',
    props.inputClass,
    iconInputPaddingClass.value,
  ),
)

const mergedLabelClass = computed(() =>
  twMerge(
    'floating-label absolute top-2 mt-[5px] inline-block origin-left transition-transform duration-150 font-medium text-sm',
    labelPositionClass.value,
    shouldFloatLabel.value ? '-translate-y-[1.25rem] scale-90' : '',
    hasError.value
      ? 'text-m-danger'
      : hasSuccess.value
        ? 'text-m-success'
        : props.disabled
          ? 'text-m-muted'
          : 'peer-placeholder-shown:text-m-muted peer-[&:not(:placeholder-shown):not(:focus)]:text-black peer-focus:text-m-primary',
    props.labelClass,
  ),
)

const iconStateClass = computed(() => {
  if (hasError.value) return 'text-m-danger'
  if (hasSuccess.value) return 'text-m-success'
  if (props.disabled) return props.iconColor
  if (isFocused.value) return 'text-m-primary'
  if (isFilled.value) return 'text-black'
  return props.iconColor
})

const chevronColorClass = computed(() => {
  if (hasError.value) return 'text-m-danger'
  if (hasSuccess.value) return 'text-m-success'
  if (isOpen.value) return 'text-m-primary'
  if (isFilled.value) return 'text-black'
  return 'text-m-muted'
})

const scheduleSearch = () => {
  if (debounceTimer) clearTimeout(debounceTimer)
  if (showMinSearch.value) return
  debounceTimer = setTimeout(() => {
    emit('search', inputValue.value)
  }, props.debounce)
}

const onInput = (event: Event) => {
  const target = event.target as HTMLInputElement
  inputValue.value = target.value
  if (!isOpen.value) isOpen.value = true
  activeIndex.value = -1

  if (hasSelection.value && target.value !== selectedOption.value?.label) {
    emit('update:modelValue', null)
    emit('select', null)
  }

  scheduleSearch()
}

const onFocus = () => {
  if (props.disabled || props.readonly) return
  isFocused.value = true
  isOpen.value = true
}

const onInputClick = () => {
  if (props.disabled || props.readonly) return
  isOpen.value = true
}

const onSelect = (option: Option) => {
  inputValue.value = option.label
  activeIndex.value = -1
  emit('update:modelValue', option.value)
  emit('select', option)
  isOpen.value = false
  isFocused.value = false
}

const closeAndCommit = () => {
  if (
    props.allowCreate
    && inputValue.value !== ''
    && inputValue.value !== selectedOption.value?.label
  ) {
    emit('update:modelValue', inputValue.value)
    emit('create', inputValue.value)
  } else if (selectedOption.value) {
    inputValue.value = selectedOption.value.label
  } else if (!props.allowCreate) {
    inputValue.value = ''
  }
  isOpen.value = false
  isFocused.value = false
}

const closeAndRevert = () => {
  if (selectedOption.value) {
    inputValue.value = selectedOption.value.label
  } else {
    inputValue.value = ''
  }
  isOpen.value = false
  isFocused.value = false
}

const onKeydown = (event: KeyboardEvent) => {
  if (event.key === 'Escape') {
    event.preventDefault()
    closeAndRevert()
    return
  }

  if (event.key === 'Enter') {
    event.preventDefault()
    if (activeIndex.value >= 0 && filteredOptions.value[activeIndex.value]) {
      onSelect(filteredOptions.value[activeIndex.value])
      return
    }
    if (props.allowCreate && inputValue.value !== '') {
      emit('update:modelValue', inputValue.value)
      emit('create', inputValue.value)
      isOpen.value = false
      isFocused.value = false
    }
    return
  }

  if (event.key === 'ArrowDown') {
    event.preventDefault()
    if (!isOpen.value) {
      isOpen.value = true
    }
    activeIndex.value = Math.min(activeIndex.value + 1, filteredOptions.value.length - 1)
    return
  }

  if (event.key === 'ArrowUp') {
    event.preventDefault()
    activeIndex.value = Math.max(activeIndex.value - 1, 0)
  }
}

const onClickOutside = (event: MouseEvent) => {
  if (!root.value) return
  if (!root.value.contains(event.target as Node)) {
    closeAndCommit()
  }
}

onMounted(() => document.addEventListener('mousedown', onClickOutside))
onBeforeUnmount(() => {
  document.removeEventListener('mousedown', onClickOutside)
  if (debounceTimer) clearTimeout(debounceTimer)
})
</script>

<style scoped>
.floating-label {
  background: white;
  padding: 0 0.25rem;
}

.grow-height {
  transition: border-color 160ms ease, box-shadow 160ms ease;
}

@media (prefers-reduced-motion: reduce) {
  .grow-height {
    transition: none;
  }
}

:deep(ul[role="listbox"]) {
  scrollbar-width: auto;
}

:deep(.select-scrollbar-primary) {
  scrollbar-color: rgb(var(--m-primary)) transparent;
}

:deep(.select-scrollbar-error) {
  scrollbar-color: #000000 transparent;
}

:deep(.select-scrollbar-success) {
  scrollbar-color: #000000 transparent;
}
</style>
