<template>
  <Combobox :modelValue="localValue" @update:modelValue="valueChanged" nullable>
    <div class="relative mt-1">
      <div
        class="relative w-full cursor-default overflow-hidden rounded-md bg-white text-left focus:outline-none focus-visible:ring-2 sm:text-sm"
      >
        <ComboboxInput
          :id="forId"
          :class="inputClass"
          :displayValue="(option: any) => getLabel(option)"
          @change="query = $event.target.value"
        />
        <ComboboxButton
          class="absolute inset-y-0 right-0 flex items-center pr-2"
        >
          <ArrowPathIcon
            class="h-5 w-5 animate-spin text-gray-400"
            aria-hidden="true"
            v-if="searching"
          />
          <ChevronUpDownIcon class="h-5 w-5 text-gray-400" aria-hidden="true" />
        </ComboboxButton>
      </div>
      <TransitionRoot
        leave="transition ease-in duration-100"
        leaveFrom="opacity-100"
        leaveTo="opacity-0"
        @after-leave="query = ''"
      >
        <ComboboxOptions
          class="absolute mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm"
        >
          <div
            v-if="options.length === 0 && (query !== '' || searching)"
            class="relative cursor-default select-none py-2 px-4 text-gray-700"
          >
            <slot name="notfound" :query="query" :searching="searching">
              {{ searching ? 'Loading..' : 'Nothing found.' }}
            </slot>
          </div>

          <ComboboxOption
            v-for="option in options"
            as="template"
            :key="option[bindValue]"
            :value="option"
            :disabled="option?.disabled"
            v-slot="{ selected, active }"
          >
            <li :class="itemClass(active, selected)">
              <slot
                name="option"
                :selected="selected"
                :active="active"
                :option="option"
              >
                <span
                  class="block truncate"
                  :class="{ 'font-medium': selected, 'font-normal': !selected }"
                >
                  {{
                    typeof bindLabel === 'function'
                      ? bindLabel(option)
                      : option[bindLabel]
                  }}
                </span>
              </slot>
              <span
                v-if="selected"
                class="absolute inset-y-0 left-0 flex items-center pl-2 text-blue-600"
              >
                <CheckIcon class="h-5 w-5" aria-hidden="true" />
              </span>
            </li>
          </ComboboxOption>
        </ComboboxOptions>
      </TransitionRoot>
    </div>
  </Combobox>
</template>

<script setup lang="ts">
import { ref, computed, watch, shallowRef } from 'vue';
import {
  Combobox,
  ComboboxInput,
  ComboboxButton,
  ComboboxOptions,
  ComboboxOption,
  TransitionRoot,
} from '@headlessui/vue';
import { CheckIcon, ChevronUpDownIcon } from '@heroicons/vue/20/solid';
import deepUnref from '../../../utils/deep-unref';
import { ArrowPathIcon } from '@heroicons/vue/24/outline';
import { useDebounceFn } from '@vueuse/shared';

const props = withDefaults(
  defineProps<{
    forId?: string;
    modelValue: any;
    initialOptions?: any[];
    searchFn?: (query: string) => Promise<any[]>;
    bindValue?: string;
    bindLabel?: string | ((obj: any) => string);
    disabled?: boolean;
    invalid?: boolean;
    placeholder?: string;
  }>(),
  {
    modelValue: null,
    disabled: false,
    invalid: false,
    bindValue: 'id',
    bindLabel: 'displayName',
  }
);

if (!props.initialOptions && !props.searchFn) {
  throw new Error(
    '[Autocomplete] you must either pass initial props or a search function!'
  );
}

const options = shallowRef<any[]>(props.initialOptions || []);
const query = ref('');
const searching = ref(false);

const inputClass = computed(() => {
  return [
    props.invalid
      ? 'border-red-300 focus:border-red-500 focus:ring-red-500'
      : 'border-gray-300 focus:border-blue-500 focus:ring-blue-500',
    `block w-full rounded-md shadow-sm sm:text-sm transition-colors duration-200`,
    'py-2 pl-3 pr-10 text-left cursor-default h-[38px] border-[1px] focus:ring-1',
  ];
});

const itemClass = (active: boolean, selected: boolean) => {
  const classList = ['relative cursor-default select-none py-2 pl-8 pr-4'];
  if (selected) {
    classList.push('text-blue-900');
  }

  if (active && selected) {
    classList.push('bg-blue-200');
  } else if (active || selected) {
    classList.push('bg-blue-100');
  } else {
    classList.push('text-gray-900');
  }

  return classList;
};

const localValue = shallowRef(props.modelValue);

const emit = defineEmits<{
  (e: 'update:modelValue', value: string | number | null): void;
}>();

const valueChanged = (option: any) => {
  localValue.value = option;
  emit('update:modelValue', deepUnref(localValue.value || null));
};

const searchFnDebounced = useDebounceFn((text: string) => {
  searching.value = true;
  props
    .searchFn?.(text)
    .then((response) => {
      options.value = response;
    })
    .catch(console.error)
    .finally(() => (searching.value = false));
}, 300);

const getLabel = (option: any) =>
  option
    ? typeof props.bindLabel === 'function'
      ? props.bindLabel(option)
      : option[props.bindLabel]
    : '';

const localSearch = (text: string) => {
  if (!props.initialOptions) return;

  options.value =
    text === ''
      ? props.initialOptions
      : props.initialOptions.filter((option) =>
          getLabel(option)
            .toLowerCase()
            .replace(/\s+/g, '')
            .includes(text.toLowerCase().replace(/\s+/g, ''))
        );
};

watch(
  query,
  (text) => {
    if (props.searchFn) {
      return searchFnDebounced(text);
    }

    localSearch(text);
  },
  { immediate: true }
);
</script>
