<script setup lang="ts">
/**
 * Props-driven async select picker composition. but presented as a button-style
 * picker — the trigger reads as a single selected value with a chevron,
 * and the Combobox panel appears beneath it.
 *
 *   <AsyncSelectPicker
 *     v-model="userId"
 *     label="User"
 *     :on-search="(q) => fetch(`/users?q=${q}`).then(r => r.json())"
 *   />
 */
import { computed, watch } from "vue"
import { ChevronsUpDown, Loader2, Search } from "@lucide/vue"
import type { SelectOptionLike } from "@/definitions/shared/select-options"
import {
  Combobox,
  ComboboxAnchor,
  ComboboxContent,
  ComboboxEmpty,
  ComboboxInput,
  ComboboxItem,
  ComboboxItemIndicator,
} from "@/components/ui/combobox"
import { FieldFrame } from "@/components/ui/field-frame"
import { useAsyncSearch } from "@/composables/useAsyncSearch"

export interface AsyncSelectPickerProps {
  modelValue?: string | number | null
  id?: string
  name?: string
  label?: string
  hideLabel?: boolean
  placeholder?: string
  required?: boolean
  readonly?: boolean
  disabled?: boolean
  helpText?: string
  errors?: string[]
  onSearch?: (query: string) => Promise<SelectOptionLike[]>
  initialOptions?: SelectOptionLike[]
  minSearchLength?: number
  debounceMs?: number
  loadingText?: string
  emptyText?: string
}

const props = withDefaults(defineProps<AsyncSelectPickerProps>(), {
  errors: () => [],
  initialOptions: () => [],
  minSearchLength: 1,
  debounceMs: 250,
  loadingText: "Loading...",
  emptyText: "No results",
})

const model = defineModel<string | number | null>("modelValue")

interface NormalisedOption {
  label: string
  value: string
  disabled: boolean
}

function normaliseOption(opt: SelectOptionLike): NormalisedOption {
  if (typeof opt === "string") {
    return { label: opt, value: opt, disabled: false }
  }
  return {
    label: opt.label,
    value: String(opt.value),
    disabled: Boolean(opt.disabled),
  }
}

const { query, results, isLoading, trigger } = useAsyncSearch({
  onSearch: props.onSearch,
  initialOptions: props.initialOptions,
  minSearchLength: props.minSearchLength,
  debounceMs: props.debounceMs,
})

watch(
  () => props.initialOptions,
  (next) => {
    if (query.value.length < props.minSearchLength) {
      results.value = next ? [...next] : []
    }
  },
)

const normalisedResults = computed<NormalisedOption[]>(() =>
  (results.value ?? []).map(normaliseOption),
)

const normalisedAll = computed<NormalisedOption[]>(() => {
  const initial = (props.initialOptions ?? []).map(normaliseOption)
  const fromResults = normalisedResults.value
  const merged = new Map<string, NormalisedOption>()
  for (const o of initial) {
    merged.set(o.value, o)
  }
  for (const o of fromResults) {
    merged.set(o.value, o)
  }
  return Array.from(merged.values())
})

const currentValue = computed<string | undefined>(() => {
  if (model.value === undefined || model.value === null) {
    return undefined
  }
  return String(model.value)
})

const selectedLabel = computed<string>(() => {
  if (currentValue.value === undefined) {
    return ""
  }
  const match = normalisedAll.value.find((o) => o.value === currentValue.value)
  return match ? match.label : currentValue.value
})

function onInput(event: Event): void {
  const next = (event.target as HTMLInputElement).value
  trigger(next)
}

function onUpdate(next: unknown): void {
  if (Array.isArray(next)) {
    model.value = next[0] === undefined ? null : (next[0] as string | number)
    return
  }
  model.value = next === undefined ? null : (next as string | number)
}

function onOpenChange(open: boolean): void {
  if (open && query.value === "") {
    results.value = props.initialOptions ? [...props.initialOptions] : []
  }
}

defineExpose({ trigger })
</script>

<template>
  <FieldFrame
    :label="label"
    :required="required"
    :hide-label="hideLabel"
    :for="id"
    :help-text="helpText"
    :errors="errors"
  >
    <Combobox
      :model-value="currentValue"
      :disabled="disabled || readonly"
      ignore-filter
      @update:model-value="onUpdate"
      @update:open="onOpenChange"
    >
      <ComboboxAnchor
        :data-element-name="name ?? undefined"
        data-testid="async-picker-anchor"
        :class="[
          'justify-between text-left',
          errors.length ? 'border-destructive ring-destructive' : '',
        ]"
      >
        <span
          v-if="selectedLabel"
          class="truncate text-sm"
          data-testid="async-picker-label"
        >
          {{ selectedLabel }}
        </span>
        <span v-else class="text-sm text-muted-foreground">
          {{ placeholder ?? "Select…" }}
        </span>
        <ChevronsUpDown class="ml-2 h-4 w-4 shrink-0 opacity-50" />
      </ComboboxAnchor>
      <ComboboxContent>
        <div class="flex items-center border-b px-2 py-1.5">
          <Search class="mr-2 h-4 w-4 shrink-0 opacity-50" />
          <ComboboxInput
            :id="id"
            :model-value="query"
            :placeholder="placeholder ?? 'Search…'"
            class="h-8"
            data-testid="async-picker-input"
            @input="onInput"
          />
          <Loader2
            v-if="isLoading"
            data-testid="async-picker-loader"
            class="ml-2 h-4 w-4 shrink-0 animate-spin text-muted-foreground"
          />
        </div>
        <div
          v-if="isLoading"
          class="px-2 py-2 text-sm text-muted-foreground"
          data-testid="async-picker-loading-text"
        >
          {{ loadingText }}
        </div>
        <ComboboxEmpty v-else-if="normalisedResults.length === 0">
          {{ emptyText }}
        </ComboboxEmpty>
        <ComboboxItem
          v-for="opt in normalisedResults"
          :key="opt.value"
          :value="opt.value"
          :disabled="opt.disabled"
        >
          <ComboboxItemIndicator />
          {{ opt.label }}
        </ComboboxItem>
      </ComboboxContent>
    </Combobox>
  </FieldFrame>
</template>
