"use client"

import * as React from "react"

import debounce from "lodash/debounce"
import {
  Check,
  ChevronDown,
  Loader2,
  X,
} from "lucide-react"

import { Badge } from "@/components/ui/badge"
import {
  Command,
  CommandEmpty,
  CommandGroup,
  CommandInput,
  CommandItem,
  CommandList,
  CommandSeparator,
} from "@/components/ui/command"
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "@/components/ui/popover"
import { cn } from "@/lib/utils"

// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------

export type RichSelectValue = string | number

export type RichSelectLabeledValue = {
  value: RichSelectValue
  label?: React.ReactNode
}

export type RichSelectOption = {
  label: React.ReactNode
  value: RichSelectValue
  disabled?: boolean
  title?: string
}

export type RichSelectOptionGroup = {
  label: React.ReactNode
  options: RichSelectOption[]
}

export type RichSelectShowSearchConfig = {
  filterOption?: boolean | ((input: string, option: RichSelectOption) => boolean)
  onSearch?: (value: string) => void
  debounceMs?: number
}

export type RichSelectTagRenderProps = {
  label: React.ReactNode
  value: RichSelectValue
  disabled?: boolean
  closable: boolean
  onClose: (e?: React.MouseEvent) => void
}

type RichSelectChangeValue =
  | RichSelectValue
  | RichSelectValue[]
  | RichSelectLabeledValue
  | RichSelectLabeledValue[]
  | undefined

export type RichSelectProps = {
  mode?: "multiple"
  value?: RichSelectChangeValue
  defaultValue?: RichSelectChangeValue
  onChange?: (
    value: RichSelectChangeValue,
    option?: RichSelectOption | RichSelectOption[]
  ) => void
  options?: RichSelectOption[]
  optionGroups?: RichSelectOptionGroup[]
  showSearch?: boolean | RichSelectShowSearchConfig
  filterOption?: boolean | ((input: string, option: RichSelectOption) => boolean)
  onSearch?: (search: string) => void
  fetchOptions?: (search: string) => Promise<RichSelectOption[]>
  debounceMs?: number
  loading?: boolean
  allowClear?: boolean
  placeholder?: React.ReactNode
  disabled?: boolean
  className?: string
  open?: boolean
  onOpenChange?: (open: boolean) => void
  labelInValue?: boolean
  optionRender?: (
    option: RichSelectOption,
    info: { index: number }
  ) => React.ReactNode
  tagRender?: (props: RichSelectTagRenderProps) => React.ReactNode
  maxTagCount?: number
  maxTagPlaceholder?: (omittedValues: RichSelectValue[]) => React.ReactNode
  notFoundContent?: React.ReactNode
  size?: "sm" | "default"
  status?: "error" | "warning"
  popupMatchSelectWidth?: boolean
  listHeight?: number
  searchPlaceholder?: string
  id?: string
  "aria-label"?: string
  "aria-labelledby"?: string
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

function isLabeledValue(v: unknown): v is RichSelectLabeledValue {
  return (
    typeof v === "object" &&
    v !== null &&
    "value" in v &&
    (typeof (v as RichSelectLabeledValue).value === "string" ||
      typeof (v as RichSelectLabeledValue).value === "number")
  )
}

function normalizeValues(
  value: RichSelectChangeValue | undefined,
  labelInValue: boolean
): RichSelectValue[] {
  if (value === undefined || value === null) return []
  const arr = Array.isArray(value) ? value : [value]
  return arr.map((item) => {
    if (labelInValue && isLabeledValue(item)) return item.value
    if (isLabeledValue(item)) return item.value
    return item as RichSelectValue
  })
}

function flattenOptions(
  options?: RichSelectOption[],
  optionGroups?: RichSelectOptionGroup[]
): RichSelectOption[] {
  const flat = [...(options ?? [])]
  optionGroups?.forEach((g) => flat.push(...g.options))
  return flat
}

function defaultFilterOption(input: string, option: RichSelectOption): boolean {
  const label =
    typeof option.label === "string"
      ? option.label
      : String(option.value)
  return label.toLowerCase().includes(input.toLowerCase())
}

function toChangeValue(
  values: RichSelectValue[],
  allOptions: RichSelectOption[],
  labelInValue: boolean,
  multiple: boolean
): RichSelectChangeValue {
  if (values.length === 0) return multiple ? [] : undefined

  const toLabeled = (v: RichSelectValue): RichSelectLabeledValue => {
    const opt = allOptions.find((o) => o.value === v)
    return { value: v, label: opt?.label ?? v }
  }

  if (labelInValue) {
    if (multiple) return values.map(toLabeled)
    return toLabeled(values[0])
  }

  if (multiple) return values
  return values[0]
}

function mergeOptionsWithSelected(
  list: RichSelectOption[],
  selectedValues: RichSelectValue[],
  cache: Map<RichSelectValue, RichSelectOption>
): RichSelectOption[] {
  const seen = new Set(list.map((o) => o.value))
  const merged = [...list]
  selectedValues.forEach((v) => {
    if (!seen.has(v)) {
      const cached = cache.get(v)
      if (cached) {
        merged.unshift(cached)
        seen.add(v)
      } else {
        merged.unshift({ value: v, label: String(v) })
        seen.add(v)
      }
    }
  })
  return merged
}

// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------

/**
 * Ant Design-like Select with search, async fetch, and multiple mode.
 *
 * @example Static single
 * ```tsx
 * <RichSelect
 *   options={[{ label: 'A', value: 'a' }]}
 *   value={value}
 *   onChange={setValue}
 *   placeholder="Select..."
 *   allowClear
 * />
 * ```
 *
 * @example Remote fetch (debounced)
 * ```tsx
 * <RichSelect
 *   showSearch
 *   fetchOptions={async (q) => {
 *     const res = await rpc.displayValues({ collection: 'users', search: q });
 *     return (res?.result?.data ?? []).map((row) => ({
 *       label: row.title,
 *       value: row._id!,
 *     }));
 *   }}
 * />
 * ```
 *
 * @example Multiple
 * ```tsx
 * <RichSelect mode="multiple" options={options} value={values} onChange={setValues} />
 * ```
 */
function RichSelect({
  mode,
  value: valueProp,
  defaultValue,
  onChange,
  options: optionsProp,
  optionGroups,
  showSearch: showSearchProp,
  filterOption: filterOptionProp,
  onSearch,
  fetchOptions,
  debounceMs = 300,
  loading: loadingProp,
  allowClear = false,
  placeholder,
  disabled = false,
  className,
  open: openProp,
  onOpenChange,
  labelInValue = false,
  optionRender,
  tagRender,
  maxTagCount,
  maxTagPlaceholder,
  notFoundContent,
  size = "default",
  status,
  popupMatchSelectWidth = true,
  listHeight = 256,
  searchPlaceholder,
  id,
  "aria-label": ariaLabel,
  "aria-labelledby": ariaLabelledBy,
}: RichSelectProps) {
  const multiple = mode === "multiple"
  const isAsync = Boolean(fetchOptions)

  const showSearchConfig =
    typeof showSearchProp === "object" ? showSearchProp : undefined
  const showSearchResolved =
    showSearchProp === undefined
      ? multiple
      : typeof showSearchProp === "boolean"
        ? showSearchProp
        : true

  const filterOptionFn = React.useMemo(() => {
    const fo =
      showSearchConfig?.filterOption ??
      filterOptionProp ??
      true
    if (fo === false) return () => false
    if (fo === true) return defaultFilterOption
    return fo
  }, [showSearchConfig?.filterOption, filterOptionProp])

  const [openUncontrolled, setOpenUncontrolled] = React.useState(false)
  const open = openProp ?? openUncontrolled
  const setOpen = React.useCallback(
    (next: boolean) => {
      if (openProp === undefined) setOpenUncontrolled(next)
      onOpenChange?.(next)
    },
    [openProp, onOpenChange]
  )

  const handleOpenChange = (next: boolean) => {
    setOpen(next)
    if (!next) {
      setTimeout(() => setSearchInput(""), 200)
    }
  }

  const [valueUncontrolled, setValueUncontrolled] = React.useState<
    RichSelectChangeValue | undefined
  >(defaultValue)
  const value = valueProp !== undefined ? valueProp : valueUncontrolled

  const selectedValues = React.useMemo(
    () => normalizeValues(value, labelInValue),
    [value, labelInValue]
  )

  const [searchInput, setSearchInput] = React.useState("")
  const [fetchedOptions, setFetchedOptions] = React.useState<RichSelectOption[]>(
    []
  )
  const [fetchLoading, setFetchLoading] = React.useState(false)
  const optionCacheRef = React.useRef<Map<RichSelectValue, RichSelectOption>>(
    new Map()
  )
  const fetchIdRef = React.useRef(0)

  const staticOptions = React.useMemo(
    () => flattenOptions(optionsProp, optionGroups),
    [optionsProp, optionGroups]
  )

  // Cache labels from static + fetched options
  React.useEffect(() => {
    staticOptions.forEach((o) => optionCacheRef.current.set(o.value, o))
    fetchedOptions.forEach((o) => optionCacheRef.current.set(o.value, o))
  }, [staticOptions, fetchedOptions])

  // Cache from labelInValue selections
  React.useEffect(() => {
    if (!labelInValue || value === undefined) return
    const items = Array.isArray(value) ? value : [value]
    items.forEach((item) => {
      if (isLabeledValue(item)) {
        optionCacheRef.current.set(item.value, {
          value: item.value,
          label: item.label ?? item.value,
        })
      }
    })
  }, [value, labelInValue])

  const runFetch = React.useCallback(
    async (search: string) => {
      if (!fetchOptions) return
      const id = ++fetchIdRef.current
      setFetchLoading(true)
      try {
        const result = await fetchOptions(search)
        if (id !== fetchIdRef.current) return
        setFetchedOptions(result)
        result.forEach((o) => optionCacheRef.current.set(o.value, o))
      } catch {
        if (id === fetchIdRef.current) setFetchedOptions([])
      } finally {
        if (id === fetchIdRef.current) setFetchLoading(false)
      }
    },
    [fetchOptions]
  )

  const debouncedFetch = React.useMemo(
    () => debounce((q: string) => runFetch(q), debounceMs),
    [runFetch, debounceMs]
  )

  const debouncedOnSearch = React.useMemo(
    () =>
      debounce((q: string) => {
        showSearchConfig?.onSearch?.(q)
        onSearch?.(q)
      }, debounceMs),
    [onSearch, showSearchConfig?.onSearch, debounceMs]
  )

  React.useEffect(() => {
    return () => {
      debouncedFetch.cancel()
      debouncedOnSearch.cancel()
    }
  }, [debouncedFetch, debouncedOnSearch])

  React.useEffect(() => {
    if (!open || !isAsync) return
    runFetch(searchInput)
  }, [open, isAsync]) // eslint-disable-line react-hooks/exhaustive-deps -- initial load on open

  const handleSearchChange = (q: string) => {
    setSearchInput(q)
    if (isAsync) {
      debouncedFetch(q)
    }
    debouncedOnSearch(q)
    if (!isAsync) {
      showSearchConfig?.onSearch?.(q)
      onSearch?.(q)
    }
  }

  const baseOptions = isAsync ? fetchedOptions : staticOptions

  const filterDisabled =
    filterOptionProp === false || showSearchConfig?.filterOption === false

  const filteredOptions = React.useMemo(() => {
    const list = mergeOptionsWithSelected(
      baseOptions,
      selectedValues,
      optionCacheRef.current
    )
    if (
      isAsync ||
      !showSearchResolved ||
      !searchInput.trim() ||
      filterDisabled
    ) {
      return list
    }
    return list.filter((opt) => filterOptionFn(searchInput, opt))
  }, [
    baseOptions,
    selectedValues,
    isAsync,
    showSearchResolved,
    searchInput,
    filterOptionFn,
    filterDisabled,
  ])

  const loading = loadingProp ?? (isAsync && fetchLoading)

  const setValue = React.useCallback(
    (nextValues: RichSelectValue[]) => {
      const allOpts = mergeOptionsWithSelected(
        flattenOptions(optionsProp, optionGroups),
        nextValues,
        optionCacheRef.current
      )
      const changeVal = toChangeValue(
        nextValues,
        allOpts,
        labelInValue,
        multiple
      )
      const selectedOpts = nextValues
        .map((v) => allOpts.find((o) => o.value === v))
        .filter(Boolean) as RichSelectOption[]

      if (valueProp === undefined) setValueUncontrolled(changeVal)
      onChange?.(
        changeVal,
        multiple ? selectedOpts : selectedOpts[0]
      )
    },
    [
      optionsProp,
      optionGroups,
      labelInValue,
      multiple,
      valueProp,
      onChange,
    ]
  )

  const toggleValue = (optionValue: RichSelectValue) => {
    if (multiple) {
      if (selectedValues.includes(optionValue)) {
        setValue(selectedValues.filter((v) => v !== optionValue))
      } else {
        setValue([...selectedValues, optionValue])
      }
    } else {
      setValue([optionValue])
      setOpen(false)
      if (showSearchResolved) setSearchInput("")
    }
  }

  const handleClear = (e: React.MouseEvent) => {
    e.preventDefault()
    e.stopPropagation()
    setValue([])
    setSearchInput("")
  }

  const handleTagClose =
    (optionValue: RichSelectValue) => (e?: React.MouseEvent) => {
      e?.preventDefault()
      e?.stopPropagation()
      setValue(selectedValues.filter((v) => v !== optionValue))
    }

  const getOptionLabel = (v: RichSelectValue): React.ReactNode => {
    if (labelInValue && value !== undefined) {
      const items = Array.isArray(value) ? value : [value]
      const found = items.find(
        (item) => isLabeledValue(item) && item.value === v
      )
      if (found && isLabeledValue(found)) return found.label ?? found.value
    }
    return optionCacheRef.current.get(v)?.label ?? String(v)
  }

  const selectedOptions = selectedValues
    .map((v) => optionCacheRef.current.get(v) ?? { value: v, label: getOptionLabel(v) })
    .filter(Boolean) as RichSelectOption[]

  const unselectedOptions = multiple
    ? filteredOptions.filter((o) => !selectedValues.includes(o.value))
    : filteredOptions

  const renderTag = (opt: RichSelectOption, closable: boolean) => {
    const tagProps: RichSelectTagRenderProps = {
      label: opt.label,
      value: opt.value,
      disabled,
      closable,
      onClose: handleTagClose(opt.value),
    }
    if (tagRender) return tagRender(tagProps)
    return (
      <Badge
        key={String(opt.value)}
        variant="secondary"
        className="gap-0.5 pr-0.5 font-normal"
      >
        <span className="max-w-[120px] truncate">{opt.label}</span>
        {closable && !disabled && (
          <button
            type="button"
            className="rounded-full p-0.5 hover:bg-muted-foreground/20"
            onMouseDown={(e) => e.stopPropagation()}
            onClick={tagProps.onClose}
            aria-label="Remove"
          >
            <X className="size-3" />
          </button>
        )}
      </Badge>
    )
  }

  const visibleTags =
    maxTagCount !== undefined
      ? selectedOptions.slice(0, maxTagCount)
      : selectedOptions
  const omittedCount =
    maxTagCount !== undefined
      ? Math.max(0, selectedOptions.length - maxTagCount)
      : 0

  const triggerClasses = cn(
    "border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-full items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
    size === "sm" ? "min-h-8 text-xs" : "min-h-9",
    multiple && "h-auto flex-wrap py-1.5",
    status === "error" && "aria-invalid border-destructive",
    status === "warning" && "border-amber-500",
    className
  )

  const renderOptionItem = (
    option: RichSelectOption,
    index: number,
    isSelected: boolean
  ) => (
    <CommandItem
      key={String(option.value)}
      value={
        typeof option.label === "string"
          ? option.label
          : String(option.value)
      }
      disabled={option.disabled}
      title={option.title}
      className="flex items-center gap-2"
      onSelect={() => {
        if (option.disabled) return
        toggleValue(option.value)
      }}
    >
      {optionRender ? (
        optionRender(option, { index })
      ) : (
        <span className="truncate">{option.label}</span>
      )}
      <Check
        className={cn(
          "ml-auto size-4 shrink-0",
          isSelected ? "opacity-100" : "opacity-0"
        )}
      />
    </CommandItem>
  )

  return (
    <Popover open={open} onOpenChange={handleOpenChange}>
      <PopoverTrigger asChild disabled={disabled}>
        <button
          type="button"
          role="combobox"
          aria-expanded={open}
          aria-invalid={status === "error" || undefined}
          aria-label={ariaLabel}
          aria-labelledby={ariaLabelledBy}
          id={id}
          disabled={disabled}
          data-slot="rich-select-trigger"
          data-size={size}
          className={triggerClasses}
        >
          <div
            className={cn(
              "flex min-w-0 flex-1 items-center gap-1",
              multiple ? "flex-wrap" : "truncate"
            )}
          >
            {multiple ? (
              selectedOptions.length > 0 ? (
                <>
                  {visibleTags.map((opt) => renderTag(opt, true))}
                  {omittedCount > 0 &&
                    (maxTagPlaceholder ? (
                      maxTagPlaceholder(
                        selectedOptions.slice(maxTagCount).map((o) => o.value)
                      )
                    ) : (
                      <span className="text-muted-foreground text-xs">
                        +{omittedCount}
                      </span>
                    ))}
                </>
              ) : (
                <span className="text-muted-foreground">{placeholder}</span>
              )
            ) : selectedValues.length > 0 ? (
              <span className="truncate">{getOptionLabel(selectedValues[0])}</span>
            ) : (
              <span className="text-muted-foreground">{placeholder}</span>
            )}
          </div>
          <div className="flex shrink-0 items-center gap-0.5">
            {allowClear && selectedValues.length > 0 && !disabled && (
              <span
                role="button"
                tabIndex={-1}
                className="text-muted-foreground hover:text-foreground rounded p-0.5"
                onMouseDown={(e) => e.stopPropagation()}
                onClick={handleClear}
              >
                <X className="size-3.5" />
              </span>
            )}
            <ChevronDown className="size-4 opacity-50" />
          </div>
        </button>
      </PopoverTrigger>
      <PopoverContent
        align="start"
        className={cn(
          "p-0",
          popupMatchSelectWidth &&
            "w-[var(--radix-popover-trigger-width)]"
        )}
      >
        <Command shouldFilter={false}>
          {showSearchResolved && (
            <CommandInput
              placeholder={searchPlaceholder}
              value={searchInput}
              onValueChange={handleSearchChange}
              className={size === "sm" ? "h-8 text-xs" : "h-9 text-sm"}
            />
          )}
          <CommandList style={{ maxHeight: listHeight }}>
            {loading ? (
              <div className="flex items-center justify-center py-6">
                <Loader2 className="text-muted-foreground size-5 animate-spin" />
              </div>
            ) : (
              <>
                <CommandEmpty>
                  {notFoundContent ?? (
                    <span className="text-muted-foreground text-xs">
                      No results
                    </span>
                  )}
                </CommandEmpty>

                {multiple && selectedOptions.length > 0 && (
                  <CommandGroup heading="Selected">
                    {selectedOptions.map((option, index) =>
                      renderOptionItem(option, index, true)
                    )}
                  </CommandGroup>
                )}

                {multiple &&
                  selectedOptions.length > 0 &&
                  unselectedOptions.length > 0 && <CommandSeparator />}

                {optionGroups && !isAsync ? (
                  optionGroups.map((group, gi) => {
                    const groupOpts = group.options
                      .filter((o) =>
                        multiple ? !selectedValues.includes(o.value) : true
                      )
                      .filter((o) => {
                        if (!showSearchResolved || !searchInput.trim()) return true
                        return filterOptionFn(searchInput, o)
                      })
                    if (groupOpts.length === 0) return null
                    return (
                      <CommandGroup key={gi} heading={group.label}>
                        {groupOpts.map((option, index) =>
                          renderOptionItem(
                            option,
                            index,
                            selectedValues.includes(option.value)
                          )
                        )}
                      </CommandGroup>
                    )
                  })
                ) : unselectedOptions.length > 0 ? (
                  <CommandGroup>
                    {unselectedOptions.map((option, index) =>
                      renderOptionItem(
                        option,
                        index,
                        selectedValues.includes(option.value)
                      )
                    )}
                  </CommandGroup>
                ) : null}
              </>
            )}
          </CommandList>
        </Command>
      </PopoverContent>
    </Popover>
  )
}

export { RichSelect }
