<template>
  <NvInput
    ref="nvSelect"
    class="nv-select"
    :class="{
      'nv-select--active': isOpen,
      'nv-select--above': isAbove,
      'nv-select--disabled': computedDisabled,
    }"
    input-classes="!w-0 !p-0"
    v-bind="inputProps"
    @update:modelValue="$emit('update:modelValue', $event)"
    @focus="activate()"
    @blur="deactivate()"
    @keyup.esc="deactivate()"
  >
    <template v-for="(_, slot) of $slots" #[slot]="scope">
      <slot :name="slot" v-bind="scope" />
    </template>
    <template #append>
      <div class="nv-select--append-inner iconbox__icon-wrap">
        <nv-icon icon="nvi-chevron-down" />
      </div>
    </template>
    <template #prependInput>
      <span
        v-if="isSingleLabelVisible"
        class="nv-select__single nv-input__input-content"
        @mousedown.prevent="isOpen ? deactivate() : activate()"
      >
        <slot name="singleLabel" :option="singleValue">
          {{ currentOptionLabel }}
        </slot>
      </span>
      <div
        class="nv-select__tags-wrap nv-input__input-content"
        :class="{ 'flex-nowrap': collapseTags }"
        v-show="visibleValues.length > 0 && !(searchable && isOpen)"
      >
        <slot
          name="selection"
          :search="search"
          :remove="removeElement"
          :values="visibleValues"
          :is-open="isOpen"
        >
          <template v-if="collapseTags">
            <slot
              name="tag"
              :option="visibleValues[0]"
              :search="search"
              :remove="removeElement"
            >
              <NvTag
                @click:close="removeElement(visibleValues[0])"
                :closable="
                  !computedDisabled &&
                  !computedReadonly &&
                  !deletableTags &&
                  !visibleValues[0]?.disabled
                "
                class="truncate mr-1"
                :size="size"
              >
                <span class="truncate">{{
                  getOptionLabel(visibleValues[0])
                }}</span>
              </NvTag>
            </slot>
            <template v-if="visibleValues.length > 1">
              <tippy
                v-if="collapseTagsTooltip"
                theme="light"
                placement="bottom"
                interactive
                :appendTo="nvSelect.$el"
              >
                <template #default>
                  <slot name="collapseTag" :count="visibleValues.length - 1">
                    <NvTag :size="size">
                      + {{ visibleValues.length - 1 }}
                    </NvTag>
                  </slot>
                </template>
                <template #content>
                  <div class="grid gap-1 grid-cols-1 py-1">
                    <div v-for="(option, index) of visibleValues">
                      <slot
                        name="tag"
                        :option="option"
                        :search="search"
                        :remove="removeElement"
                      >
                        <NvTag
                          @click:close="removeElement(option)"
                          :closable="
                            !computedDisabled &&
                            !computedReadonly &&
                            !deletableTags &&
                            !option?.disabled
                          "
                          class="truncate"
                          :size="size"
                        >
                          <span class="truncate">{{
                            getOptionLabel(option)
                          }}</span>
                        </NvTag>
                      </slot>
                    </div>
                  </div>
                </template>
              </tippy>
              <template v-else>
                <slot name="collapseTag" :count="visibleValues.length - 1">
                  <NvTag :size="size"> + {{ visibleValues.length - 1 }} </NvTag>
                </slot>
              </template>
            </template>
          </template>
          <template v-else>
            <div class="flex flex-wrap gap-1 my-2">
              <template
                v-for="(option, index) of visibleValues"
                @mousedown.prevent
              >
                <slot
                  name="tag"
                  :option="option"
                  :search="search"
                  :remove="removeElement"
                >
                  <NvTag
                    @click:close="removeElement(option)"
                    :closable="
                      !computedReadonly &&
                      !computedDisabled &&
                      !option?.disabled
                    "
                    class="truncate"
                    :size="size"
                  >
                    <span class="truncate">{{ getOptionLabel(option) }}</span>
                  </NvTag>
                </slot>
              </template>
            </div>
          </template>
        </slot>
      </div>
      <!-- Actual search input -->
      <input
        ref="inputSearch"
        type="text"
        v-model="search"
        @input.prevent="updateSearch($event.target.value)"
        class="nv-input__input"
        :class="inputClasses"
      />
    </template>
    <template #prependControl>
      <transition name="nv-connected-overlay">
        <div
          v-show="isOpen"
          class="nv-select__content-wrapper"
          tabindex="-1"
          :style="{ maxHeight: optimizedHeight + 'px' }"
          @mousedown.prevent
        >
          <slot name="beforeList"></slot>
          <ul class="nv-select__content" role="listbox">
            <template v-if="groupValues">
              <template v-for="(group, index) in filteredOptions">
                <li class="nv-select__group-label" v-text="group[groupLabel]" />
                <ul>
                  <NvSelectItem
                    v-for="(option, index) in group[groupValues]"
                    :disabled="option.disabled"
                    :selected="isSelected(option)"
                    :multiple="multiple"
                    :label="getOptionLabel(option)"
                    :icon="getIconCheckbox(option)"
                    @click.stop="select(option)"
                  >
                    <slot
                      name="option"
                      :option="option"
                      :search="search"
                      :index="index"
                    />
                  </NvSelectItem>
                </ul>
                <hr v-if="index !== filteredOptions.length - 1" />
              </template>
            </template>
            <template v-else>
              <NvSelectItem
                v-for="(option, index) in filteredOptions"
                :disabled="option.disabled"
                :selected="isSelected(option)"
                :multiple="multiple"
                :label="getOptionLabel(option)"
                :icon="getIconCheckbox(option)"
                @click.stop="select(option)"
              >
                <slot
                  name="option"
                  :option="option"
                  :search="search"
                  :index="index"
                />
              </NvSelectItem>
            </template>
            <li v-show="showNoOptions && filteredOptions.length === 0">
              <span
                v-if="loading"
                class="nv-select__option nv-select__option--empty"
              >
                <slot name="loading">Загрузка...</slot>
              </span>
              <span
                v-else
                class="nv-select__option nv-select__option--empty nv-select__option--loading"
              >
                <slot name="noOptions">Список пуст.</slot>
              </span>
            </li>
          </ul>
          <slot name="afterList"></slot>
        </div>
      </transition>
    </template>
  </NvInput>
</template>

<script>
import { computed, inject, ref, nextTick, watch } from "vue";
import { isObject } from "@vue/shared";
import { useInputProps, useGetInputProps } from "../../composables/input";
import { NvInput } from "../NvInput";
import NvSelectItem from "./NvSelectItem.vue";

// ToDo: управление с клавиатуры

function isEmpty(opt) {
  if (opt === 0) return false;
  if (Array.isArray(opt) && opt.length === 0) return true;
  return !opt;
}

export default {
  props: {
    ...useInputProps,

    /**
     * Массив доступных опций: Objects, Strings или Integers.
     * @type {Array}
     */
    options: {
      type: Array,
      required: true,
    },
    /**
     * Включить/отключить поиск в опциях
     * @default false
     * @type {Boolean}
     */
    searchable: {
      type: Boolean,
      default: false,
    },
    /**
     * Задает, следует ли фильтровать результаты на основе поискового запроса.
     * Полезно для асинхронной фильтрации, где необходимо выполнять поиск по более сложным данным и фильтрация не нужна.
     * @type {Boolean}
     */
    internalSearch: {
      type: Boolean,
      default: true,
    },
    /**
     * Эквивалентно атрибуту `multiple` на входе `<select>`.
     * @default false
     * @type {Boolean}
     */
    multiple: {
      type: Boolean,
      default: false,
    },
    /**
     * Показывать блок с сообщением о пустом списке доступных options
     * @default true
     * @type {Boolean}
     */
    showNoOptions: {
      type: Boolean,
      default: true,
    },
    /**
     * Возвращает текст для показа у выбранного элемента
     * @default false
     * @type {Function}
     */
    customLabel: {
      type: Function,
      default(option, label) {
        if (isEmpty(option)) return "";
        return isObject(option) && label ? option[label] : option;
      },
    },
    /**
     * Включить/отключить закрытие после выбора опции
     * @default true
     * @type {Boolean}
     */
    closeOnSelect: {
      type: Boolean,
      default: true,
    },
    /**
     * Очистить ввод для поиска после выбора
     * @default true
     * @type {Boolean}
     */
    clearOnSelect: {
      type: Boolean,
      default: true,
    },
    /**
     * Ключ для сравнения объектов
     * @default value
     * @type {String}
     */
    itemValue: {
      type: String,
      default: "value",
    },
    /**
     * Поле для сравнения объектов
     * @default label
     * @type {String}
     */
    itemText: {
      type: String,
      default: "label",
    },
    /**
     * Изменяет поведение выбора, чтобы возвращать объект напрямую,
     * а не значение, указанное с помощью item-value
     */
    returnObject: {
      type: Boolean,
    },
    /**
     * Разрешить удаление всех выбранных значений
     * @default true
     * @type {Boolean}
     */
    allowEmpty: {
      type: Boolean,
      default: true,
    },
    /**
     * Предотвратить стирание значения поиска
     * @default false
     * @type {Boolean}
     */
    preserveSearch: {
      type: Boolean,
      default: false,
    },
    /**
     * Фиксирует направление открытия
     * @default undefined
     * @type {String}
     */
    openDirection: {
      type: String,
      required: false,
      validator: (value) => ["bottom", "top"].includes(value),
    },
    /**
     * Задает максимальное значение стиля высоты выпадающего списка
     * @default 300
     * @type {Integer}
     */
    maxHeight: {
      type: Number,
      default: 300,
    },
    /**
     * Имя свойства, содержащего значения группы
     * @default undefined
     * @type {String}
     */
    groupValues: {
      type: String,
    },
    /**
     * Имя свойства, содержащего заголовок группы
     * @default undefined
     * @type {String}
     */
    groupLabel: {
      type: String,
    },
    /**
     * Следует ли сворачивать теги при множественном выборе
     * @default false
     * @type {Boolean}
     */
    collapseTags: {
      type: Boolean,
      default: false,
    },
    /**
     * Показывать ли все выбранные теги при наведении курсора мыши на метку свернутых тегов.
     * Чтобы использовать это, collapse-tags должно иметь значение true
     * @default false
     * @type {Boolean}
     */
    collapseTagsTooltip: {
      type: Boolean,
      default: false,
    },
    /**
     * Показывать ли иконку удаления у тегов при множественном выборе
     * @default false
     * @type {Boolean}
     */
    deletableTags: {
      type: Boolean,
      default: false,
    },
  },
  components: { NvInput, NvSelectItem },
  emits: [
    "update:modelValue",
    "select",
    "remove",
    "open",
    "close",
    "search-change",
  ],

  setup(props, { emit }) {
    const inputProps = computed(() => useGetInputProps(props));
    const isOpen = ref(false);

    const parentDisabled = inject("parentDisabled", undefined);
    const computedDisabled = computed(() => parentDisabled || props.disabled);

    const parentReadonly = inject("parentReadonly", undefined);
    const computedReadonly = computed(() => parentReadonly || props.readonly);

    const search = ref("");
    watch(search, (newVal) => {
      emit("search-change", newVal);
    });

    const cachedItems = computed(() => {
      return filterDuplicates(
        cachedItems.value
          ? [...cachedItems.value, ...props.options]
          : props.options
      );
    });

    function filterDuplicates(arr) {
      const uniqueValues = new Map();
      arr.forEach((item) => {
        // Do not return empty values if existant
        if (isEmpty(item)) return;

        const val = isObject(item) ? item[props.itemValue] : item;
        if (!uniqueValues.has(val)) {
          uniqueValues.set(val, item);
        }
      });
      return Array.from(uniqueValues.values());
    }

    const optimizedHeight = ref(props.maxHeight);
    const preferredOpenDirection = ref("bottom");

    const inputSearch = ref(null);
    const nvSelect = ref(null);

    /**
     * Открывает выпадающий список селекта.
     * Устанавливает значение isOpen равным TRUE
     */
    function activate() {
      if (isOpen.value || computedDisabled.value || computedReadonly.value)
        return;

      adjustPosition();
      isOpen.value = true;

      if (props.searchable) {
        if (!props.preserveSearch) search.value = "";
        nextTick(() => inputSearch.value && inputSearch.value.focus());
      } else {
        inputSearch.value.focus();
      }
      emit("open");
    }

    /**
     * Закрывает выпадающий список селекта.
     * Устанавливает значение isOpen равным FALSE
     */
    function deactivate() {
      if (!isOpen.value) return;

      isOpen.value = false;
      nvSelect.value.blured();
      if (!props.preserveSearch) search.value = "";
      emit("close", getValue());
    }

    /**
     * Обновляет значение поиска
     * @param {String} query
     */
    function updateSearch(query) {
      search.value = query;
    }

    /**
     * Обновляет переменную, используемую для
     * определения того, куда раскрывать выпадающий список
     */
    function adjustPosition() {
      if (typeof window === "undefined") return;
      if (!nvSelect.value) return;

      const controlRect = nvSelect.value.$el
        .querySelector(".nv-input__control")
        .getBoundingClientRect();

      const spaceAbove = controlRect.top;
      const spaceBelow = window.innerHeight - controlRect.bottom;
      const hasEnoughSpaceBelow = spaceBelow > props.maxHeight;

      if (
        hasEnoughSpaceBelow ||
        spaceBelow > spaceAbove ||
        props.openDirection === "bottom"
      ) {
        preferredOpenDirection.value = "bottom";
        optimizedHeight.value = Math.min(spaceBelow - 16, props.maxHeight);
      } else {
        preferredOpenDirection.value = "top";
        optimizedHeight.value = Math.min(spaceAbove - 16, props.maxHeight);
      }
    }

    /**
     * Рассчитывает итоговое направление, куда раскрываться - вверх или вниз
     * @type {ComputedRef<unknown>}
     */
    const isAbove = computed(() => {
      if (props.openDirection === "top") {
        return true;
      } else if (props.openDirection === "bottom") {
        return false;
      } else {
        return preferredOpenDirection.value === "top";
      }
    });

    /**
     * Returns the internalValue in a way it can be emited to the parent
     * @returns {Object||Array||String||Integer}
     */
    function getValue() {
      return props.multiple
        ? internalValue.value
        : internalValue.value.length === 0
        ? null
        : internalValue.value[0];
    }

    /**
     * Возвращает пустую строку, если значение варианета равно null/undefined
     * Возвращает результаты customLabel().
     *
     * @param  {Object||String||Integer} Passed option
     * @returns {Object||String}
     */
    function getOptionLabel(option) {
      if (isEmpty(option)) return "";

      const label = props.customLabel(option, props.itemText);
      if (isEmpty(label)) return "";
      return label;
    }

    /**
     * Приводит modelValue к массиву
     * @type {ComputedRef<Object|Object[]|*[]>}
     */
    const internalValue = computed(() => {
      return props.modelValue || props.modelValue === 0
        ? Array.isArray(props.modelValue)
          ? props.modelValue
          : [props.modelValue]
        : [];
    });

    /**
     * Возвращает массив ключей modelValue
     * @type {ComputedRef<unknown>}
     */
    const valueKeys = computed(() => {
      if (props.returnObject) {
        return internalValue.value.map((element) => element[props.itemValue]);
      } else {
        return internalValue.value;
      }
    });

    /**
     * Возвращает список доступных вариантов в линейном массиве.
     * @type {ComputedRef<*[]>}
     */
    const flatOptions = computed(() => {
      let allOptions = [];
      if (props.groupValues) {
        props.options.forEach((group) => {
          if (group[props.groupValues])
            allOptions = [...allOptions, ...group[props.groupValues]];
        });
      } else {
        allOptions = [...props.options];
      }
      return allOptions;
    });

    function getOptionByValue(value) {
      const items = cachedItems.value
        ? [...flatOptions.value, ...cachedItems.value]
        : flatOptions.value;
      return items.find((option) => {
        const curItem = isObject(option) ? option[props.itemValue] : option;
        return curItem === value;
      });
    }

    /**
     * Возвращает набор выбранных данных, который используется для отображения выбранных в слоте prependInput
     * @type {ComputedRef<*[]>}
     */
    const visibleValues = computed(() => {
      let res = [];
      if (props.multiple) {
        if (props.returnObject) {
          res = [...internalValue.value];
        } else {
          internalValue.value.forEach((item) => {
            const curItem = getOptionByValue(item);
            res.push(curItem || item);
          });
        }
      }
      return res;
    });

    const isSingleLabelVisible = computed(() => {
      return (
        !props.multiple &&
        (singleValue.value || singleValue.value === 0) &&
        (!isOpen.value || !props.searchable) &&
        !visibleValues.value.length
      );
    });

    const singleValue = computed(() => {
      if (!props.returnObject && isObject(flatOptions.value[0])) {
        return getOptionByValue(internalValue.value[0]);
      } else {
        return internalValue.value[0];
      }
    });

    const currentOptionLabel = computed(() => {
      return internalValue.value.length
        ? getOptionLabel(
            getOptionByValue(internalValue.value[0]) || internalValue.value[0]
          )
        : "";
    });

    const filteredOptions = computed(() => {
      let options = props.options.concat();

      if (props.internalSearch) {
        const curSearch = search.value || "";
        const normalizedSearch = curSearch.toLowerCase().trim();

        options = props.groupValues
          ? filterGroups(
              options,
              normalizedSearch,
              props.itemText,
              props.groupValues,
              props.groupLabel,
              props.customLabel
            )
          : filterOptions(
              options,
              normalizedSearch,
              props.itemText,
              props.customLabel
            );
      }
      return options;
    });

    function filterGroups(
      groups,
      search,
      label,
      values,
      groupLabel,
      customLabel
    ) {
      return groups
        .map((group) => {
          if (!group[values]) {
            console.warn(
              `Options passed to nv-select do not contain groups, despite the config.`
            );
            return [];
          }

          const groupOptions = filterOptions(
            group[values],
            search,
            label,
            customLabel
          );

          return groupOptions.length
            ? {
                [groupLabel]: group[groupLabel],
                [values]: groupOptions,
              }
            : [];
        })
        .filter((g) => !isEmpty(g));
    }

    function filterOptions(options, search, label, customLabel) {
      return search
        ? options
            .filter((option) => includes(customLabel(option, label), search))
            .sort(
              (a, b) =>
                customLabel(a, label).length - customLabel(b, label).length
            )
        : options;
    }

    function includes(str, query) {
      if (str === undefined) str = "undefined";
      if (str === null) str = "null";
      if (str === false) str = "false";
      const text = str.toString().toLowerCase();
      return text.indexOf(query.trim()) !== -1;
    }

    const inputClasses = computed(() => {
      if (!props.searchable) {
        return "!w-0 !pl-0";
      }
      if (
        props.searchable ||
        (props.multiple && props.modelValue && props.modelValue.length)
      ) {
        // Hide input by setting the width to 0 allowing it to receive focus
        return isOpen.value ? "w-full" : "!w-0 !pl-0";
      }
      return "";
    });

    /**
     * Определяет, присутствует ли данный элемент уже
     * в результирующем значении
     * @param  {Object||String||Integer} option passed element to check
     * @returns {Boolean} returns true if element is selected
     */
    function isSelected(option) {
      const opt = isObject(option) ? option[props.itemValue] : option;
      return valueKeys.value.indexOf(opt) > -1;
    }

    /**
     * Возвращает, какую иконку показывать рядом с вариантов выбора в списке при множественном выборе
     * @param option
     * @returns {string}
     */
    function getIconCheckbox(option) {
      return isSelected(option) ? "nvi-checkbox-on" : "nvi-checkbox-off";
    }

    /**
     * Добавьте переданный вариант в список выбранных.
     * Если вариант уже выбран -> удаляет его из выбранных.
     *
     * @param  {Object||String||Integer} вариант для выбора/кдаления
     */
    function select(option) {
      if (isObject(option) && option.disabled) return;
      const value =
        isObject(option) && !props.returnObject
          ? option[props.itemValue]
          : option;
      const isSelectedOption = isSelected(option);

      if (isSelectedOption) {
        removeElement(option);
        return;
      }

      emit("select", value);

      if (props.multiple) {
        emit("update:modelValue", internalValue.value.concat([value]));
      } else {
        emit("update:modelValue", value);
      }
      if (props.clearOnSelect) search.value = "";
      if (props.closeOnSelect) deactivate();
    }

    /**
     * Удаляет данный вариант из выбранных.
     * Дополнительно проверяет props.allowEmpty, можно ли удалять, когда
     * вариант является последним выбранным.
     *
     * @param  {type} option description
     * @return {type}        description
     */
    function removeElement(option, shouldClose = true) {
      if (computedDisabled.value || computedReadonly.value) return;
      if (isObject(option) && option.disabled) return;
      if (!props.allowEmpty && internalValue.value.length <= 1) {
        deactivate();
        return;
      }

      const index = isObject(option)
        ? valueKeys.value.indexOf(option[props.itemValue])
        : valueKeys.value.indexOf(option);

      emit("remove", option);
      if (props.multiple) {
        const newValue = internalValue.value
          .slice(0, index)
          .concat(internalValue.value.slice(index + 1));
        emit("update:modelValue", newValue);
      } else {
        emit("update:modelValue", null);
      }

      if (props.closeOnSelect && shouldClose) deactivate();
    }

    return {
      activate,
      computedDisabled,
      computedReadonly,
      currentOptionLabel,
      deactivate,
      filteredOptions,
      getIconCheckbox,
      getOptionLabel,
      inputClasses,
      inputProps,
      inputSearch,
      isAbove,
      isOpen,
      isSelected,
      isSingleLabelVisible,
      nvSelect,
      optimizedHeight,
      removeElement,
      search,
      select,
      singleValue,
      updateSearch,
      visibleValues,
    };
  },
};
</script>
<style lang="scss" scoped>
.nv-select--disabled {
  @apply text-subline;
}
</style>
