<template>
  <div
    ref="rootRef"
    class="vm-select"
    :class="{ 'is-full': isFullWidth, 'is-open': open, 'is-disabled': disabled }"
    :style="triggerStyle"
  >
    <button
      ref="triggerRef"
      type="button"
      class="vm-crud-select vm-select__trigger"
      :class="{ 'is-full': isFullWidth }"
      :disabled="disabled"
      @click="toggle"
    >
      <span
        class="vm-select__label"
        :class="{ 'is-placeholder': !displayLabel }"
      >
        {{ displayLabel || placeholder || crudLabel('pleaseSelect') }}
      </span>
      <i
        v-if="clearable && hasValue"
        class="ri-close-circle-fill vm-select__clear"
        @click.stop="clear"
      />
      <i class="ri-arrow-down-s-line vm-select__caret" aria-hidden="true" />
    </button>

    <Teleport to="body">
      <FocusScope
        v-if="open"
        as-child
        loop
        :trapped="true"
        @unmount-auto-focus="onPanelUnmountAutoFocus"
      >
        <div
          ref="panelRef"
          class="vm-select__panel vm-dialog-float"
          :style="panelStyle"
          @pointerdown.stop
          @mousedown.stop
        >
          <div v-if="filterable" class="vm-select__search">
            <i class="ri-search-line vm-select__search-ico" aria-hidden="true" />
            <input
              ref="searchRef"
              class="vm-select__search-input"
              :value="search"
              :placeholder="crudLabel('searchEllipsis')"
              @input="onSearchInput"
              @keydown.esc.stop="closePanel"
            />
            <i
              v-if="search"
              class="ri-close-circle-fill vm-select__search-clear"
              @click.stop="clearSearch"
            />
          </div>
          <div class="vm-select__list vm-scroll" @scroll.passive="onListScroll">
            <button
              v-for="opt in filtered"
              :key="String(opt[valueKey])"
              type="button"
              class="vm-select__option"
              :class="{ 'is-active': String(opt[valueKey]) === inner }"
              @click="pick(opt)"
            >
              {{ opt[labelKey] }}
            </button>
            <vm-loading
              v-if="lazy && loading"
              mode="footer"
              status="loading"
            />
            <p v-else-if="!filtered.length" class="vm-select__empty">{{ crudLabel('noMatch') }}</p>
            <vm-loading
              v-else-if="lazy && !hasMore"
              mode="footer"
              status="end"
            />
          </div>
        </div>
      </FocusScope>
    </Teleport>
  </div>
</template>

<script setup lang="ts">
import { crudLabel } from '../style'
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
import { FocusScope } from 'reka-ui'
import { useCrud } from '../useCrud'
import { POPUP_TRIGGER_GAP } from '../../lib/dialog-float'

defineOptions({ name: 'vm-select' })

export type VmSelectLoadQuery = {
  page: number
  size: number
  keyWord: string
  /** true：业务页应追加到现有 options；false：整表替换 */
  append: boolean
}

const props = withDefaults(
  defineProps<{
    modelValue?: string | number | boolean | null
    options?: Array<Record<string, unknown>>
    prop?: string
    labelKey?: string
    valueKey?: string
    placeholder?: string
    /** 默认 100% 铺满；搜索栏可传 150px / 180px */
    width?: string
    disabled?: boolean
    refreshOnChange?: boolean
    api?: string | (() => Promise<unknown>)
    /**
     * 是否显示面板顶部搜索框；默认 true。
     * false 时 UI 与默认同构，仅隐藏搜索（不再走另一套原生 Select）。
     */
    filterable?: boolean
    /** 有值时展示清除图标，点击置空 */
    clearable?: boolean
    /**
     * 懒加载 / 翻页：打开、搜索、触底时 emit `load`，由页面请求后回推 `:options`。
     * 与静态 options、`api` 并存；开启后本地不做关键字过滤。
     */
    lazy?: boolean
    loading?: boolean
    hasMore?: boolean
    pageSize?: number
  }>(),
  {
    labelKey: 'label',
    valueKey: 'value',
    refreshOnChange: true,
    width: '100%',
    filterable: true,
    clearable: true,
    lazy: false,
    loading: false,
    hasMore: false,
    pageSize: 20,
  },
)

const emit = defineEmits<{
  'update:modelValue': [v: string | number | boolean | '']
  change: [v: string | number | boolean | '']
  load: [q: VmSelectLoadQuery]
}>()

const crud = useCrud()
const remote = ref<Array<Record<string, unknown>>>([])
const rootRef = ref<HTMLElement | null>(null)
const triggerRef = ref<HTMLElement | null>(null)
const panelRef = ref<HTMLElement | null>(null)
const searchRef = ref<HTMLInputElement | null>(null)
const open = ref(false)
const search = ref('')
const panelStyle = ref<Record<string, string>>({})
const suppressOpen = ref(false)
/** lazy 模式下已请求到的页码（下一次触底 = page + 1） */
const loadedPage = ref(0)
/** 已 emit load、等待业务回推；防触底连发 */
const awaitingLoad = ref(false)
let searchTimer: ReturnType<typeof setTimeout> | null = null

const inner = computed(() =>
  props.modelValue == null || props.modelValue === ''
    ? ''
    : String(props.modelValue),
)

const hasValue = computed(() => inner.value !== '')

const isFullWidth = computed(
  () => !props.width || props.width === '100%' || props.width === '100',
)

const triggerStyle = computed(() =>
  isFullWidth.value ? undefined : { width: props.width },
)

const normalized = computed(() => {
  const list = (props.options?.length ? props.options : remote.value) || []
  return list as Array<Record<string, unknown>>
})

const displayLabel = computed(() => {
  if (!inner.value) return ''
  const hit = normalized.value.find(
    (o) => String(o[props.valueKey]) === inner.value,
  )
  return hit ? String(hit[props.labelKey] ?? '') : inner.value
})

const filtered = computed(() => {
  // lazy：关键字交给业务页，列表即 options 全量
  if (props.lazy || !props.filterable) return normalized.value
  const q = search.value.trim().toLowerCase()
  if (!q) return normalized.value
  return normalized.value.filter((o) => {
    const label = String(o[props.labelKey] ?? '').toLowerCase()
    const value = String(o[props.valueKey] ?? '').toLowerCase()
    return label.includes(q) || value.includes(q)
  })
})

watch(
  () => props.api,
  async (api) => {
    if (!api || props.lazy) return
    try {
      const req =
        typeof api === 'function'
          ? api()
          : (crud.service[api] as (() => Promise<unknown>) | undefined)?.()
      const res = await req
      remote.value = Array.isArray(res) ? res : []
    } catch {
      remote.value = []
    }
  },
  { immediate: true },
)

watch(open, (v) => {
  if (!v) {
    search.value = ''
    if (searchTimer) {
      clearTimeout(searchTimer)
      searchTimer = null
    }
  }
})

watch(
  () => [props.loading, props.options] as const,
  () => {
    if (props.loading) return
    awaitingLoad.value = false
  },
  { deep: true },
)

function emitLoad(page: number, append: boolean) {
  if (awaitingLoad.value || props.loading) return
  awaitingLoad.value = true
  emit('load', {
    page,
    size: props.pageSize,
    keyWord: search.value.trim(),
    append,
  })
  loadedPage.value = page
}

function requestFirstPage() {
  if (!props.lazy) return
  emitLoad(1, false)
}

function requestNextPage() {
  if (!props.lazy || !props.hasMore) return
  emitLoad(loadedPage.value + 1, true)
}

/** 提交时还原 options 里的原始类型 */
function resolveOptionValue(v: unknown): string | number | boolean {
  const s = v == null ? '' : String(v)
  if (!s) return ''
  const hit = normalized.value.find(
    (o) => String(o[props.valueKey]) === s,
  )
  if (!hit) return s
  const raw = hit[props.valueKey]
  if (typeof raw === 'number' || typeof raw === 'boolean') return raw
  return s
}

function onChange(v: unknown) {
  const val = resolveOptionValue(v)
  emit('update:modelValue', val)
  emit('change', val)
  if (props.prop && props.refreshOnChange) {
    crud.refresh({ page: 1, [props.prop]: val === '' ? undefined : val })
  }
}

function pick(opt: Record<string, unknown>) {
  onChange(opt[props.valueKey])
  closePanel()
}

function onSearchInput(e: Event) {
  search.value = (e.target as HTMLInputElement).value
  if (!props.lazy) return
  if (searchTimer) clearTimeout(searchTimer)
  searchTimer = setTimeout(() => {
    searchTimer = null
    if (!open.value) return
    emitLoad(1, false)
  }, 300)
}

function clearSearch() {
  search.value = ''
  searchRef.value?.focus()
  if (props.lazy) emitLoad(1, false)
}

function clear() {
  closePanel()
  onChange('')
}

function onListScroll(e: Event) {
  if (!props.lazy || !props.hasMore) return
  const el = e.target as HTMLElement
  if (el.scrollHeight - el.scrollTop - el.clientHeight > 48) return
  requestNextPage()
}

function syncPanelPos() {
  const el = triggerRef.value
  if (!el) return
  const r = el.getBoundingClientRect()
  const width = Math.max(r.width, 200)
  panelStyle.value = {
    position: 'fixed',
    top: `${r.bottom + POPUP_TRIGGER_GAP}px`,
    left: `${r.left}px`,
    width: `${width}px`,
    zIndex: '200',
  }
}

function onPanelUnmountAutoFocus(event: Event) {
  event.preventDefault()
}

function closePanel() {
  if (!open.value) return
  suppressOpen.value = true
  open.value = false
  search.value = ''
  nextTick(() => {
    requestAnimationFrame(() => {
      suppressOpen.value = false
    })
  })
}

async function openPanel() {
  if (props.disabled || suppressOpen.value) return
  open.value = true
  await nextTick()
  syncPanelPos()
  if (props.filterable) searchRef.value?.focus()
  requestFirstPage()
}

async function toggle() {
  if (props.disabled) return
  if (open.value) {
    closePanel()
    return
  }
  await openPanel()
}

function isInside(el: Node | null) {
  if (!el) return false
  if (rootRef.value?.contains(el)) return true
  if (panelRef.value?.contains(el)) return true
  return el instanceof Element && Boolean(el.closest('.vm-select__panel'))
}

function onDocPointer(e: Event) {
  if (!open.value) return
  const t = e.target as Node | null
  if (!isInside(t)) closePanel()
}

function onWinChange() {
  if (open.value) syncPanelPos()
}

onMounted(() => {
  document.addEventListener('pointerdown', onDocPointer, true)
  window.addEventListener('resize', onWinChange)
  window.addEventListener('scroll', onWinChange, true)
})
onUnmounted(() => {
  if (searchTimer) clearTimeout(searchTimer)
  document.removeEventListener('pointerdown', onDocPointer, true)
  window.removeEventListener('resize', onWinChange)
  window.removeEventListener('scroll', onWinChange, true)
})
</script>

<style lang="scss" scoped>
.vm-crud-select {
  height: var(--vm-control-height);
  border-radius: var(--vm-control-radius);
  font-size: var(--vm-control-font-size);

  &.is-full {
    width: 100% !important;
    max-width: 100%;
  }
}

.vm-select {
  position: relative;
  box-sizing: border-box;
  min-width: 0;

  &.is-full {
    width: 100%;
  }

  &.is-disabled {
    opacity: 0.55;
    pointer-events: none;
  }
}

.vm-select__trigger {
  display: flex;
  box-sizing: border-box;
  width: 100%;
  align-items: center;
  justify-content: flex-start;
  gap: 6px;
  padding: 0 var(--vm-control-padding-x);
  border: 1px solid var(--input);
  background: var(--muted);
  color: var(--foreground);
  cursor: pointer;
  outline: none;
}

.vm-select__label {
  overflow: hidden;
  flex: 1 1 auto;
  min-width: 0;
  text-align: left;
  text-overflow: ellipsis;
  white-space: nowrap;

  &.is-placeholder {
    color: var(--muted-foreground);
  }
}

.vm-select__caret {
  flex: 0 0 auto;
  margin-left: auto;
  color: var(--muted-foreground);
  font-size: 14px;
}

.vm-select__clear {
  flex: 0 0 auto;
  margin-left: auto;
  color: var(--muted-foreground);
  font-size: 14px;
  cursor: pointer;

  &:hover {
    color: var(--foreground);
  }
}

.vm-select__clear + .vm-select__caret {
  margin-left: 0;
}

.vm-select__panel {
  display: flex;
  max-height: min(280px, 45vh);
  flex-direction: column;
  overflow: hidden;
  border: 1px solid var(--border);
  border-radius: var(--vm-control-radius);
  background: var(--card);
  box-shadow: var(--shadow-card);
}

.vm-select__search {
  display: flex;
  flex: 0 0 auto;
  align-items: center;
  gap: 6px;
  margin: 8px 8px 0;
  padding: 0 8px;
  border: 1px solid var(--input);
  border-radius: 8px;
  background: var(--muted);
}

.vm-select__search-ico {
  flex: 0 0 auto;
  color: var(--muted-foreground);
  font-size: 14px;
}

.vm-select__search-input {
  flex: 1 1 auto;
  min-width: 0;
  height: 28px;
  border: none;
  background: transparent;
  color: var(--foreground);
  font-size: 13px;
  outline: none;
}

.vm-select__search-clear {
  flex: 0 0 auto;
  color: var(--muted-foreground);
  font-size: 14px;
  cursor: pointer;
}

.vm-select__list {
  flex: 1 1 auto;
  min-height: 0;
  margin: 6px 0;
  padding: 0 6px 6px;
  overflow: auto;
}

.vm-select__option {
  display: block;
  box-sizing: border-box;
  width: 100%;
  padding: 7px 8px;
  border: none;
  border-radius: 8px;
  background: transparent;
  color: var(--foreground);
  font-size: 13px;
  text-align: left;
  cursor: pointer;

  &:hover {
    background: var(--muted);
  }

  &.is-active {
    background: color-mix(in srgb, var(--brand) 12%, var(--card));
    color: var(--brand);
    font-weight: 550;
  }
}

.vm-select__empty {
  margin: 0;
  padding: 12px 8px;
  color: var(--muted-foreground);
  font-size: 12px;
  text-align: center;
}
</style>
