<!-- prettier-ignore-start -->
<!-- eslint-disable -->
<script setup lang="ts">
import type { IconType, ThemeType } from '@bagelink/vue'
import { Btn, useDevice, useEscapeKey } from '@bagelink/vue'
import { autoPlacement, computePosition, flip, offset, shift } from '@floating-ui/dom'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'

export type Side = 'top' | 'right' | 'bottom' | 'left'
export type Alignment = 'start' | 'end'
export type AlignedPlacement = 'auto' | Side | `${Side}-${Alignment}`
export type TriggerEvent = 'click' | 'hover'

defineOptions({ inheritAttrs: false, name: 'DropDown' })

// Module-level counter so each Dropdown instance gets a unique id
let _bglIdCounter = 0

const props = withDefaults(defineProps<{
	value?: string
	thin?: boolean
	flat?: boolean
	disabled?: boolean
	icon?: IconType
	color?: ThemeType
	triggerClass?: string
	iconEnd?: IconType
	border?: boolean
	outline?: boolean
	/** Solid fill + a hairline border on the trigger (keeps the background, unlike `outline`). */
	frame?: boolean
	round?: boolean
	placement?: AlignedPlacement
	disablePlacement?: boolean
	noAutoFocus?: boolean
	autoHide?: boolean
	triggers?: TriggerEvent[]
	showTriggers?: TriggerEvent[]
	hideTriggers?: TriggerEvent[]
	popperTriggers?: TriggerEvent[]
	popperShowTriggers?: TriggerEvent[]
	popperHideTriggers?: TriggerEvent[]
	showGroup?: string
	card?: boolean
	delay?: number | { show: number; hide: number }
	/** Override the anchor element used for positioning. When provided, the #trigger slot is not rendered. */
	referenceEl?: Element | null
}>(), {
	thin: true,
	flat: true,
	card: true,
	placement: 'bottom-start',
	disablePlacement: false,
	autoHide: true,
	triggers: () => ['click'],
	popperTriggers: () => [],
	popperShowTriggers: () => [],
	popperHideTriggers: () => [],
})

const emit = defineEmits<{ show: []; hide: [] }>()

const shown = defineModel('shown', { type: Boolean, default: false })

const triggerWrapRef = ref<HTMLElement | null>(null)
const popoverRef = ref<HTMLElement | null>(null)

// Tracks whether the popover was opened via keyboard (Enter/Space/Arrow) so we know
// whether to move focus into the menu on open.
let openedViaKeyboard = false

// Unique id for this popover instance, used to track parent–child relationships across teleported popovers
const bglId = `bgl-dd-${++_bglIdCounter}`
const teleportTarget = ref<string | Element>('body')

const { isMobile } = useDevice()

const shouldDisablePositioning = computed(() => props.disablePlacement && isMobile.value)
const isSmallScreen = isMobile

const effectiveTriggers = computed(() =>
	isSmallScreen.value
		? props.triggers.map(t => (t === 'hover' ? 'click' : t))
		: props.triggers
)
const isClickTrigger = computed(() => effectiveTriggers.value.includes('click'))
const isHoverTrigger = computed(() => effectiveTriggers.value.includes('hover'))

const isPopperHoverTrigger = computed(() => {
	if (props.popperTriggers.length > 0) return props.popperTriggers.includes('hover')
	return props.triggers.includes('hover') && !isSmallScreen.value
})

const iconSet = computed(() => {
	if (props.icon !== undefined && props.icon !== '') return props.icon
	if (props.value === undefined || props.value === '') return 'more_vert'
	return undefined
})

const resolvedDelay = computed((): { show: number; hide: number } => {
	if (props.delay !== undefined) {
		return typeof props.delay === 'number'
			? { show: props.delay, hide: props.delay }
			: props.delay
	}
	if (props.triggers.includes('hover') && !isSmallScreen.value) return { show: 0, hide: 10 }
	return { show: 0, hide: 0 }
})

// ShowGroup registry — shared across all Dropdown instances on the page
const groupRegistry = new Map<string, Set<() => void>>()

async function updatePosition() {
	const triggerEl = (props.referenceEl ?? triggerWrapRef.value?.firstElementChild) as HTMLElement | null
	if (!popoverRef.value || !triggerEl || shouldDisablePositioning.value) return

	const isAuto = props.placement === 'auto'
	const { x, y } = await computePosition(triggerEl, popoverRef.value, {
		placement: isAuto ? undefined : (props.placement as any),
		strategy: 'fixed',
		middleware: [
			offset(4),
			...(isAuto ? [autoPlacement()] : [flip()]),
			shift({ padding: 8 }),
		],
	})

	if (popoverRef.value) {
		popoverRef.value.style.left = `${x}px`
		popoverRef.value.style.top = `${y}px`
	}
}

function isOpen(): boolean {
	return !!popoverRef.value?.matches(':popover-open')
}

async function internalShow() {
	if (props.disabled || isOpen()) return

	teleportTarget.value = triggerWrapRef.value?.closest('dialog[open]') ?? 'body'

	if (props.showGroup && groupRegistry.has(props.showGroup)) {
		groupRegistry.get(props.showGroup)!.forEach(fn => fn())
	}

	// Wait for Teleport to move the element to the new target before promoting to top layer
	await nextTick()
	if (!popoverRef.value) return

	// Tag with our own id and, if the trigger lives inside another open popover, tag with that owner's id.
	// This lets ancestor Dropdowns skip closing when a click lands inside our (teleported) popover.
	popoverRef.value.dataset.bglId = bglId
	const ownerPopover = triggerWrapRef.value?.closest<HTMLElement>('[data-bgl-id]')
	if (ownerPopover?.dataset.bglId) popoverRef.value.dataset.bglOwner = ownerPopover.dataset.bglId
	else delete popoverRef.value.dataset.bglOwner

	// Show hidden first so floating-ui can measure it, then position, then reveal
	popoverRef.value.style.opacity = '0'
	popoverRef.value.showPopover()
	await updatePosition()
	popoverRef.value.style.opacity = ''

	// For keyboard-driven opens, move focus into the menu so arrow keys work immediately.
	if (openedViaKeyboard) {
		focusFirstItem()
		openedViaKeyboard = false
	}
}

function internalHide(opts?: { restoreFocus?: boolean }) {
	if (!isOpen()) return
	// Only restore focus to the trigger for keyboard-driven closes (Esc/click).
	// Hover-driven closes must NOT steal focus back, otherwise the trigger gets a
	// stray :focus-visible ring after the pointer has already moved elsewhere.
	const restoreFocus = opts?.restoreFocus ?? true
	if (restoreFocus && !props.noAutoFocus) {
		const focusable = triggerWrapRef.value?.querySelector<HTMLElement>(
			'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
		)
		focusable?.focus()
	}
	popoverRef.value?.hidePopover()
}

// --- Keyboard accessibility helpers -------------------------------------------------

const FOCUSABLE_SELECTOR = [
	'a[href]',
	'button:not([disabled])',
	'input:not([disabled])',
	'select:not([disabled])',
	'textarea:not([disabled])',
	'[tabindex]:not([tabindex="-1"])',
].join(',')

function getFocusableItems(): HTMLElement[] {
	if (!popoverRef.value) return []
	return Array.from(popoverRef.value.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR))
		.filter(el => el.offsetParent !== null || el === document.activeElement)
}

function focusItemAt(index: number) {
	const items = getFocusableItems()
	if (items.length === 0) return
	const wrapped = (index + items.length) % items.length
	items[wrapped]?.focus()
}

// Move focus into the menu (first focusable item) — used for keyboard-driven opens.
function focusFirstItem() {
	if (props.noAutoFocus) return
	focusItemAt(0)
}

// Arrow-key navigation while the popover is open.
function onPopoverKeydown(e: KeyboardEvent) {
	const items = getFocusableItems()
	if (items.length === 0) return
	const currentIndex = items.indexOf(document.activeElement as HTMLElement)

	switch (e.key) {
		case 'ArrowDown':
			e.preventDefault()
			focusItemAt(currentIndex < 0 ? 0 : currentIndex + 1)
			break
		case 'ArrowUp':
			e.preventDefault()
			focusItemAt(currentIndex < 0 ? items.length - 1 : currentIndex - 1)
			break
		case 'Home':
			e.preventDefault()
			focusItemAt(0)
			break
		case 'End':
			e.preventDefault()
			focusItemAt(items.length - 1)
			break
	}
}

const show = internalShow
function hide() { internalHide() }

// Sync external shown model → popover state
watch(shown, (val) => {
	if (val && !isOpen()) internalShow()
	else if (!val && isOpen()) internalHide()
})

// Sync popover state → shown model
function onPopoverToggle(e: Event) {
	const te = e as ToggleEvent
	if (te.newState === 'open') {
		shown.value = true
		nextTick(updatePosition)
		emit('show')
	} else {
		shown.value = false
		emit('hide')
	}
}

// Click-outside to auto-hide
function onDocumentPointerDown(e: PointerEvent) {
	if (!props.autoHide || !isOpen()) return
	const target = e.target as Node
	if (popoverRef.value?.contains(target) || triggerWrapRef.value?.contains(target)) return
	// Don't close if the click landed inside a descendant popover (e.g. a SelectInput inside this
	// dropdown that teleports its popup to <body>). We detect this via a data-bgl-owner attribute
	// that each Dropdown sets on its popover element to point to the nearest ancestor popover's id.
	const openPopovers = document.querySelectorAll<HTMLElement>('[popover]:popover-open')
	const myId = popoverRef.value?.dataset.bglId
	for (const p of Array.from(openPopovers)) {
		if (p === popoverRef.value) continue
		if (p.contains(target) && myId && p.dataset.bglOwner === myId) return
	}
	// Pointer landed elsewhere — let the click move focus naturally, don't yank it back to the trigger.
	internalHide({ restoreFocus: false })
}

// Esc key — LIFO via shared composable (nested layers close first)
useEscapeKey(() => internalHide(), () => isOpen())

// Hover delay timers
let showTimer: ReturnType<typeof setTimeout> | undefined
let hideTimer: ReturnType<typeof setTimeout> | undefined
function clearTimers() { clearTimeout(showTimer); clearTimeout(hideTimer) }

// Guard against double toggles within the same tick. The <Btn> trigger handles keydown.enter/space
// itself AND the browser also fires a native click for Enter/Space on a <button>, which would
// otherwise toggle the popover twice (open → immediately close, the "opens for a second" bug).
let lastToggleAt = 0
function toggle(viaKeyboard: boolean) {
	const now = Date.now()
	if (now - lastToggleAt < 50) return
	lastToggleAt = now
	openedViaKeyboard = viaKeyboard
	isOpen() ? internalHide() : internalShow()
}

function onTriggerClick(e?: MouseEvent) {
	if (!isClickTrigger.value) return
	// A click via keyboard (Enter/Space) reports detail === 0; a real pointer click reports >= 1.
	toggle(!!e && e.detail === 0)
}

// Keyboard activation on the trigger. Works for ALL trigger types (including hover-only) so the
// dropdown is reachable by keyboard users — WCAG requires hover-revealed content to also open on focus.
function onTriggerKeydown(e: KeyboardEvent) {
	switch (e.key) {
		case 'Enter':
		case ' ':
		case 'Spacebar':
			e.preventDefault()
			toggle(true)
			break
		case 'ArrowDown':
			e.preventDefault()
			openedViaKeyboard = true
			// Arrow keys are a deliberate request to navigate, so move focus into the menu
			// even when noAutoFocus is set (noAutoFocus only suppresses focus-on-open).
			if (isOpen()) focusItemAt(0)
			else internalShow()
			break
		case 'ArrowUp':
			e.preventDefault()
			openedViaKeyboard = true
			if (isOpen()) focusItemAt(-1)
			else internalShow()
			break
	}
}
function onTriggerMouseenter() {
	if (!isHoverTrigger.value) return
	clearTimeout(hideTimer)
	showTimer = setTimeout(internalShow, resolvedDelay.value.show)
}
function onTriggerMouseleave() {
	if (!isHoverTrigger.value) return
	clearTimeout(showTimer)
	hideTimer = setTimeout(() => internalHide({ restoreFocus: false }), resolvedDelay.value.hide)
}
function onPopoverMouseenter() {
	if (!isPopperHoverTrigger.value) return
	clearTimeout(hideTimer)
}
function onPopoverMouseleave() {
	if (!isPopperHoverTrigger.value) return
	hideTimer = setTimeout(() => internalHide({ restoreFocus: false }), resolvedDelay.value.hide)
}

function onScroll() {
	if (isOpen()) updatePosition()
}

onMounted(() => {
	document.addEventListener('pointerdown', onDocumentPointerDown, true)
	document.addEventListener('scroll', onScroll, { capture: true, passive: true })
	if (props.showGroup) {
		if (!groupRegistry.has(props.showGroup)) groupRegistry.set(props.showGroup, new Set())
		groupRegistry.get(props.showGroup)!.add(internalHide)
	}
})

onBeforeUnmount(() => {
	document.removeEventListener('pointerdown', onDocumentPointerDown, true)
	document.removeEventListener('scroll', onScroll, true)
	clearTimers()
	if (props.showGroup) groupRegistry.get(props.showGroup)?.delete(internalHide)
	if (isOpen()) popoverRef.value?.hidePopover()
})

defineExpose({ show, hide, shown })
</script>

<template>
	<div style="display: contents">
		<!-- Trigger wrapper — omitted when referenceEl is provided externally -->
		<div
			v-if="!referenceEl"
			ref="triggerWrapRef" v-bind="$attrs"
			aria-haspopup="menu"
			:aria-expanded="shown"
			:aria-controls="bglId"
			@mouseenter="onTriggerMouseenter" @mouseleave="onTriggerMouseleave" @focus="onTriggerMouseenter"
			@blur="onTriggerMouseleave" @keydown="onTriggerKeydown"
		>
			<slot name="trigger" :show :hide :shown>
				<Btn
					:class="triggerClass" :iconEnd :icon="iconSet" :value :thin :flat :outline :frame :round :color
					:disabled @click="onTriggerClick"
				/>
			</slot>
		</div>

		<!-- Popup — teleported to closest dialog[open] or body, promoted to top layer via popover -->
		<Teleport :to="teleportTarget">
			<div
				:id="bglId" ref="popoverRef" popover="manual" class="bgl-dropdown m-0 p-0 border-none"
				role="menu"
				style="border-radius: var(--bgl-card-border-radius);"
				:class="{ 'bgl-dropdown--no-positioning': shouldDisablePositioning, 'bg-white shadow': props.card }"
				@toggle="onPopoverToggle" @mouseenter="onPopoverMouseenter" @mouseleave="onPopoverMouseleave"
				@focus="onPopoverMouseenter" @blur="onPopoverMouseleave" @keydown="onPopoverKeydown"
			>
				<div class="bgl-dropdown__backdrop" />
				<div class="bgl-dropdown__content overflow-hidden" :class="{ 'display-flex column bgl-dropdown__content--card': props.card }"
				style="border-radius: var(--bgl-card-border-radius);">
					<slot :hide :show :shown />
				</div>
			</div>
		</Teleport>
	</div>
</template>

<style>

/* Reset browser UA popover styles */
.bgl-dropdown {
margin: 0;
padding: 0;
border: none;
background: transparent;
overflow: visible;
position: fixed;
inset: unset;
top: 0;
left: 0;
}

/* Mobile full-screen slide-up mode */
.bgl-dropdown.bgl-dropdown--no-positioning {
inset: 0;
width: 100%;
height: 100%;
display: flex;
align-items: flex-end;
overflow: hidden;
}

.bgl-dropdown.bgl-dropdown--no-positioning .bgl-dropdown__backdrop {
position: absolute;
inset: 0;
background: rgba(0 0 0 / 90%);
}

.bgl-dropdown.bgl-dropdown--no-positioning .bgl-dropdown__content {
width: 100%;
position: relative;
transform: translateY(0);
}

@starting-style {
.bgl-dropdown.bgl-dropdown--no-positioning:popover-open .bgl-dropdown__content {
	transform: translateY(100%);
}
}

.bgl-dropdown.bgl-dropdown--no-positioning .bgl-dropdown__content {
transition: transform 0.15s ease-out;
}

/* Hide backdrop in normal mode */
.bgl-dropdown:not(.bgl-dropdown--no-positioning) .bgl-dropdown__backdrop {
display: none;
}

/* Desktop open animation */
@keyframes bgl-dropdown-enter {
from {
	opacity: 0;
	transform: translateY(-4px) scaleY(0.95);
}

to {
	opacity: 1;
	transform: translateY(0) scaleY(1);
}
}

.bgl-dropdown:not(.bgl-dropdown--no-positioning):popover-open {
animation: bgl-dropdown-enter 0.18s ease 0.04s both;
transform-origin: top left;
}

/* Default menu breathing room so items don't touch the rounded top/bottom edges.
   Applies to the card content itself so plain menu content (e.g. a list of Btns)
   gets the same padding as content wrapped in a .bgl_card — no consumer py-* needed. */
.bgl-dropdown__content--card {
padding-block: 0.5rem;
padding-inline: 0.375rem;
}
/* When the slot already provides a .bgl_card, that card owns the padding — drop
   the container's own padding so the spacing isn't applied twice. */
.bgl-dropdown__content--card:has(> .bgl_card) {
padding: 0;
}
.bgl-dropdown__content--card .bgl_card{
padding-block: 0.5rem;
padding-inline: 0.375rem;
}

</style>
<!-- prettier-ignore-end -->
<!-- eslint-enable -->
