<script lang="ts">
/**
 * A component for selecting one value from a list of options
 */
export default {
  name: "PlDropdown",
};
</script>

<script lang="ts" setup generic="M = unknown">
import { computed, reactive, ref, unref, useTemplateRef, watch, watchPostEffect } from "vue";
import SvgRequired from "../../assets/images/required.svg?raw";
import { getErrorMessage } from "../../helpers/error.ts";
import { tap } from "../../helpers/functions";
import { deepEqual } from "../../helpers/objects";
import { normalizeListOptions } from "../../helpers/utils";
import type { ListOption, ListOptionNormalized, MaskIconName16, MaskIconName24 } from "../../types";
import DoubleContour from "../../utils/DoubleContour.vue";
import { useLabelNotch } from "../../utils/useLabelNotch";
import LongText from "../LongText.vue";
import { PlIcon16 } from "../PlIcon16";
import { PlIcon24 } from "../PlIcon24";
import { PlSvg } from "../PlSvg";
import { PlTooltip } from "../PlTooltip";
import OptionList from "./OptionList.vue";
import "./pl-dropdown.scss";
import type { LOption } from "./types";
import { useGroupBy } from "./useGroupBy";

const emit = defineEmits<{
  /**
   * Emitted when the model value is updated.
   */
  (e: "update:modelValue", value: M | undefined): void;
}>();

const props = withDefaults(
  defineProps<{
    /**
     * The current selected value of the dropdown.
     */
    modelValue: M;
    /**
     * The label text for the dropdown field (optional)
     */
    label?: string;
    /**
     * List of available options for the dropdown
     */
    options?: Readonly<ListOption<M>[]>;
    /**
     * A helper text displayed below the dropdown when there are no errors (optional).
     */
    helper?: string;
    /**
     * A helper text displayed below the dropdown when there are no options yet or options is undefined (optional).
     */
    loadingOptionsHelper?: string;
    /**
     * Error message displayed below the dropdown (optional)
     */
    error?: unknown;
    /**
     * Shows red border even without an error message
     */
    errorStatus?: boolean;
    /**
     * Placeholder text shown when no value is selected.
     */
    placeholder?: string;
    /**
     * Text shown in the field when `modelValue` has no matching option — e.g. its
     * upstream block was deleted. Rendered italic in error color.
     */
    missingValueLabel?: string;
    /**
     * Enables a button to clear the selected value (default: false)
     */
    clearable?: boolean;
    /**
     * If `true`, the dropdown component is marked as required.
     */
    required?: boolean;
    /**
     * If `true`, the dropdown component is disabled and cannot be interacted with.
     */
    disabled?: boolean;
    /**
     * Custom icon (16px) class for the dropdown arrow (optional)
     */
    arrowIcon?: MaskIconName16;
    /**
     * Custom icon (24px) class for the dropdown arrow (optional)
     */
    arrowIconLarge?: MaskIconName24;
    /**
     * Option list item size
     */
    optionSize?: "small" | "medium";
    /**
     * Makes some of corners not rounded
     * */
    groupPosition?:
      | "top"
      | "bottom"
      | "left"
      | "right"
      | "top-left"
      | "top-right"
      | "bottom-left"
      | "bottom-right"
      | "middle";
  }>(),
  {
    label: "",
    helper: undefined,
    loadingOptionsHelper: undefined,
    error: undefined,
    showErrorMessage: true,
    placeholder: "...",
    missingValueLabel: "Value not available",
    clearable: false,
    required: false,
    disabled: false,
    arrowIcon: undefined,
    arrowIconLarge: undefined,
    optionSize: "small",
    options: undefined,
    groupPosition: undefined,
  },
);

const rootRef = ref<HTMLElement | undefined>();
const input = ref<HTMLInputElement | undefined>();

const optionListRef = useTemplateRef<InstanceType<typeof OptionList>>("optionListRef");

const data = reactive({
  search: "",
  activeIndex: -1,
  open: false,
  optionsHeight: 0,
});

const findActiveIndex = () =>
  tap(
    orderedRef.value.findIndex((o) => deepEqual(o.value, props.modelValue)),
    (v) => (v < 0 ? 0 : v),
  );

const updateActive = () => (data.activeIndex = findActiveIndex());

const isLoadingOptions = computed(() => {
  return props.options === undefined;
});

/**
 * Loading spinner should not be shown when the dropdown is explicitly disabled,
 * even if options are undefined. The disabled state takes precedence.
 */
const showLoadingSpinner = computed(() => {
  return !props.disabled && isLoadingOptions.value;
});

const isDisabled = computed(() => {
  if (isLoadingOptions.value) {
    return true;
  }

  return props.disabled;
});

const selectedIndex = computed(() => {
  return (props.options ?? []).findIndex((o) => deepEqual(o.value, props.modelValue));
});

const hasValue = computed(() => {
  return props.modelValue !== undefined && props.modelValue !== null;
});

const isMissing = computed(() => {
  return hasValue.value && selectedIndex.value === -1 && !isLoadingOptions.value;
});

const computedError = computed(() => {
  if (isLoadingOptions.value) {
    return undefined;
  }

  if (props.error) {
    return getErrorMessage(props.error);
  }

  // The field renders the missing state via `missingValueLabel`; no need to
  // duplicate it as a helper error.
  return undefined;
});

const optionsRef = computed<LOption<M>[]>(() =>
  normalizeListOptions(props.options ?? []).map((opt, index) => ({
    ...opt,
    index,
    isSelected: index === selectedIndex.value,
    isActive: index === data.activeIndex,
  })),
);

const textValue = computed(() => {
  const options = unref(optionsRef);
  const item: ListOption | undefined = options.find((o) => deepEqual(o.value, props.modelValue));
  return item?.label;
});

const computedPlaceholder = computed(() => {
  if (!data.open && hasValue.value) {
    return "";
  }

  return textValue.value ? String(textValue.value) : props.placeholder;
});

const filteredRef = computed(() => {
  const options = optionsRef.value;

  if (data.search) {
    return options.filter((o: ListOptionNormalized) => {
      const search = data.search.toLowerCase();

      if (o.label.toLowerCase().includes(search)) {
        return true;
      }

      if (o.description && o.description.toLowerCase().includes(search)) {
        return true;
      }

      if (typeof o.value === "string") {
        return o.value.toLowerCase().includes(search);
      }

      return o.value === data.search;
    });
  }

  return options;
});

const { orderedRef, groupsRef, restRef } = useGroupBy(filteredRef, "group");

const tabindex = computed(() => (isDisabled.value ? undefined : "0"));

const selectOption = (v: M | undefined) => {
  emit("update:modelValue", v);
  data.search = "";
  data.open = false;
  rootRef?.value?.focus();
};

const selectOptionWrapper = (v: unknown) => {
  selectOption(v as M | undefined);
};

const clear = () => emit("update:modelValue", undefined);

const setFocusOnInput = () => input.value?.focus();

const toggleOpen = () => {
  data.open = !data.open;
  if (!data.open) {
    data.search = "";
  }
};

const onInputFocus = () => (data.open = true);

const onFocusOut = (event: FocusEvent) => {
  const relatedTarget = event.relatedTarget as Node | null;

  if (
    !rootRef.value?.contains(relatedTarget) &&
    !optionListRef.value?.listRef?.contains(relatedTarget)
  ) {
    data.search = "";
    data.open = false;
  }
};

const handleKeydown = (e: { code: string; preventDefault(): void }) => {
  if (!["ArrowDown", "ArrowUp", "Enter", "Escape"].includes(e.code)) {
    return;
  } else {
    e.preventDefault();
  }

  const { open, activeIndex } = data;

  if (!open) {
    if (e.code === "Enter") {
      data.open = true;
    }
    return;
  }

  if (e.code === "Escape") {
    data.open = false;
    rootRef.value?.focus();
  }

  const ordered = orderedRef.value;

  const { length } = ordered;

  if (!length) {
    return;
  }

  if (e.code === "Enter") {
    selectOption(ordered.find((it) => it.index === activeIndex)?.value);
  }

  const localIndex = ordered.findIndex((it) => it.index === activeIndex) ?? -1;

  const delta = e.code === "ArrowDown" ? 1 : e.code === "ArrowUp" ? -1 : 0;

  const newIndex = Math.abs(localIndex + delta + length) % length;

  data.activeIndex = ordered[newIndex].index ?? -1;
};

useLabelNotch(rootRef);

watch(() => props.modelValue, updateActive, { immediate: true });

watch(
  () => data.open,
  (open) => (open ? input.value?.focus() : ""),
);

watchPostEffect(() => {
  // eslint-disable-next-line @typescript-eslint/no-unused-expressions
  data.search; // to watch

  if (data.activeIndex >= 0 && data.open) {
    optionListRef.value?.scrollIntoActive();
  }
});
</script>

<template>
  <div class="pl-dropdown__envelope" @click="setFocusOnInput">
    <div
      ref="rootRef"
      :tabindex="tabindex"
      class="pl-dropdown"
      :class="{ open: data.open, error: error || errorStatus, disabled: isDisabled }"
      @keydown="handleKeydown"
      @focusout="onFocusOut"
    >
      <div class="pl-dropdown__container">
        <div class="pl-dropdown__field">
          <input
            ref="input"
            v-model="data.search"
            type="text"
            tabindex="-1"
            :disabled="isDisabled"
            :placeholder="computedPlaceholder"
            spellcheck="false"
            autocomplete="chrome-off"
            @focus="onInputFocus"
          />

          <div v-if="!data.open" class="input-value">
            <LongText v-if="isMissing" class="input-value--missing">
              {{ missingValueLabel }}
            </LongText>
            <LongText v-else-if="textValue !== undefined">
              {{ textValue }}
            </LongText>
          </div>

          <div class="pl-dropdown__controls">
            <PlIcon24 v-if="showLoadingSpinner" name="loading" />
            <PlIcon16
              v-if="clearable && hasValue"
              class="clear"
              name="delete-clear"
              @click.stop="clear"
            />
            <slot name="append" />
            <div class="pl-dropdown__arrow-wrapper" @click.stop="toggleOpen">
              <PlIcon24 v-if="arrowIconLarge" :name="arrowIconLarge" class="arrow-icon" />
              <PlIcon16 v-else-if="arrowIcon" :name="arrowIcon" class="arrow-icon" />
              <PlIcon16 v-else name="chevron-down" class="arrow-icon arrow-icon-default" />
            </div>
          </div>
        </div>
        <label v-if="label">
          <PlSvg v-if="required" :uri="SvgRequired" />
          <span>{{ label }}</span>
          <PlTooltip v-if="$slots.tooltip" class="info" position="top">
            <template #tooltip>
              <slot name="tooltip" />
            </template>
          </PlTooltip>
        </label>
        <OptionList
          v-if="data.open"
          ref="optionListRef"
          :root-ref="rootRef!"
          :groups="groupsRef"
          :rest="restRef"
          :option-size="optionSize"
          :select-option="selectOptionWrapper"
        />
        <DoubleContour class="pl-dropdown__contour" :group-position="groupPosition" />
      </div>
    </div>
    <div v-if="computedError" class="pl-dropdown__error">{{ computedError }}</div>
    <div v-else-if="showLoadingSpinner && loadingOptionsHelper" class="pl-dropdown__helper">
      {{ loadingOptionsHelper }}
    </div>
    <div v-else-if="helper" class="pl-dropdown__helper">{{ helper }}</div>
  </div>
</template>
