/* eslint-disable ts/no-this-alias */
/* eslint-disable style/max-statements-per-line -- compact `if (x) { y; }` is intentional throughout this file */
/*
 * Pure-TSX <DropdownList> — no jQuery, no select2. See MIGRATION.md for the
 * design contract, prop reference, and async/virtualization usage.
 */
import type { VNode } from 'vue';
import type { DropdownButtonItemArgs } from '../dropdown-button/dropdown-button-item';
import type { FormItemWrapperArgs, MarginType } from '../form/form-item-wrapper';
import Sortable from 'sortablejs';
import { Prop, toNative } from 'vue-facing-decorator';
import { globalState } from '../../app/global-state';
import PowerduckState from '../../app/powerduck-state';
import TsxComponent, { Component } from '../../app/vuetsx';
import { capitalize } from '../../common/extensions/string-extensions';
import { MobileModeConfig } from '../../common/mobile-mode-config';
import { isNullOrEmpty } from '../../common/utils/is-null-or-empty';
import { PortalUtils } from '../../common/utils/utils';
import VueUtils from '../../common/utils/vue-utils';
import FormItemWrapper from '../form/form-item-wrapper';
import CheckBox, { CheckBoxSize, CheckBoxSkin } from '../input/checkbox';
import LoadingIndicator from '../loading-indicator';
import Modal, { ModalMobileMode, ModalSize } from '../modal/modal';
import ModalBody from '../modal/modal-body';
import './css/dropdown.css';

// ─────────────────────────────────────────────────────────────────────────────
// Public enums / shapes
// ─────────────────────────────────────────────────────────────────────────────

export enum MultiselectMode {
	Tags = 0,
	Checkboxes = 1,
}

export enum MultiSelectExclusivity {
	Inclusive = 0,
	Exclusive = 1,
}

export class DropdownOptionGroup {
	isOptGroup: true;
	text: string;
	children: Array<string> | Array<any>;
}

export interface DropdownListOption {
	id: number | string;
	text: string;
	dataRow?: any;
}

export interface DropdownListButtonClickedArgs {
	item: any;
}

export interface DropdownListButton {
	iconCss: string;
	cssClass?: string;
	clicked: (e: { item: any }) => void;
}

export interface DropdownListTagAddedArgs {
	closeSelection: boolean;
	tagArr: string[];
}

export interface DropdownTrailingButtonArgs {
	cssClass: string;
	icon: string;
	text: string;
	clicked: (row: any) => void;
}

/**
 * Args passed to an async loader. The loader is responsible for including any
 * rows whose IDs appear in `selectedIds` in the response — see MIGRATION.md.
 */
export interface AsyncLoaderArgs {
	selectedIds: string[];
	searchQuery: string;
	limit: number;
	page: number;
}

export interface AsyncLoaderResult {
	items: any[];
	hasMore: boolean;
}

export type AsyncLoaderFn = (args: AsyncLoaderArgs) => Promise<AsyncLoaderResult>;

/**
 * Internal descriptor type used by the virtualized renderer. Each item the
 * panel can show is described once, then either fully rendered (non-virt) or
 * only rendered if inside the visible window (virtualized).
 */
type ResultRowDescriptor =
	| { kind: 'group'; item: DropdownDisplayArgs }
	| { kind: 'option'; item: DropdownDisplayArgs; flatIndex: number }
	| { kind: 'add'; flatIndex: number }
	| { kind: 'loading' }
	| { kind: 'error'; msg: string }
	| { kind: 'no-results' };

type RowToString = (row: any) => string;

export interface DropdownDisplayArgs {
	id: string;
	text: string;
	dataRow: any;
	children?: DropdownDisplayArgs[];
	isOptionGroup?: boolean;
}

export type CustomRenderOriginator = 'default' | 'mobile';

interface DropdownListArgs extends FormItemWrapperArgs {
	placeholder?: string;
	blocked?: boolean;
	disabled?: boolean;
	disableSearch?: boolean;
	containerCssClass?: string;
	options: Array<string> | Array<DropdownOptionGroup> | Array<any>;
	selected: string | any | Array<string> | Array<any>;
	displayMode?: 'default' | 'inline';
	displayMember?: string | RowToString;
	valueMember?: string | RowToString;
	multiselect?: boolean;
	multiselectMode?: MultiselectMode;
	closeOnSelect?: boolean;
	name?: string;
	autocomplete?: string;
	mobileShortMode?: boolean;
	changedEventDelay?: number;
	/**
	 * Legacy select2 width-auto knob retained for back-compat — the new
	 * component sizes its panel to the trigger width anyway. Accepted as a
	 * prop but ignored.
	 */
	dropdownAutoWidth?: boolean;
	// Tags mode: free-text add, sortable, per-chip action buttons
	tags?: boolean;
	tagsSortable?: boolean;
	tagsShouldPrependContent?: boolean;
	tagsButtons?: (item: any) => DropdownListButton[];
	tagsAdded?: (e: DropdownListTagAddedArgs) => void;
	tagsNewPlaceLast?: boolean;
	trailingButton?: DropdownTrailingButtonArgs;
	allowExclusiveSearch?: boolean;
	changed: (newValue: any, exclusivity?: MultiSelectExclusivity) => void;
	noResultsFound?: () => void;
	customRenderOption?: (h: any, state: DropdownDisplayArgs, originator?: CustomRenderOriginator) => any;
	customRenderSelectionResult?: (h: any, state: DropdownDisplayArgs, originator?: CustomRenderOriginator) => any;
	customIdProperty?: string;
	/**
	 * When set, the dropdown enters async mode: it ignores `options` and asks
	 * the loader for paged data. The loader is invoked on open (page 0 with
	 * selectedIds), after a 300ms debounce on search-text change, and when the
	 * user scrolls within 100px of the bottom of the option list.
	 */
	asyncLoader?: AsyncLoaderFn;
	/** Page size passed to the async loader. Defaults to 50. */
	asyncPageSize?: number;
	/**
	 * When true, the option list virtualizes (only renders window-visible
	 * rows). Row height is measured from the first rendered row. Enable for
	 * lists that may exceed ~300 items.
	 */
	virtualizeOptions?: boolean;
}

const NORMALIZE_RE = /\p{Diacritic}/gu;

const normalize = (text: string): string => {
	if (text == null) {
		return '';
	}

	return text.toString().toLowerCase().normalize('NFD').replace(NORMALIZE_RE, '').trim();
};

@Component
class DropdownListComponent extends TsxComponent<DropdownListArgs> implements DropdownListArgs {
	// ── FormItemWrapper passthrough props ──────────────────────────────────────
	@Prop() label!: string | VNode;
	@Prop() labelButtons!: DropdownButtonItemArgs[];
	@Prop() blocked!: boolean;
	@Prop() mandatory!: boolean;
	@Prop() hint: string;
	@Prop() subtitle!: string;
	@Prop() cssClass!: string;
	@Prop() appendIcon: string;
	@Prop() prependIcon: string;
	@Prop() appendIconClicked: () => void;
	@Prop() prependIconClicked: () => void;
	@Prop() appendClicked: () => void;
	@Prop() prependClicked: () => void;
	@Prop() showClearValueButton!: boolean;
	@Prop() maxWidth?: number;
	@Prop() marginType?: MarginType;
	@Prop() wrap!: boolean;

	// ── Dropdown-specific props ───────────────────────────────────────────────
	@Prop() name!: string;
	@Prop() autocomplete!: string;
	@Prop() options!: Array<string> | Array<any>;
	@Prop() displayMember!: (row: any) => string | string;
	@Prop() valueMember!: (row: any) => string | string;
	@Prop() selected!: string | any | Array<string> | Array<any>;
	@Prop() displayMode?: 'default' | 'inline';
	@Prop() changed!: (newValue: any, exclusivity?: MultiSelectExclusivity) => void;
	@Prop() multiselect!: boolean;
	@Prop() multiselectMode!: MultiselectMode;
	@Prop() closeOnSelect!: boolean;
	@Prop() placeholder!: string;
	@Prop() disabled?: boolean;
	@Prop() disableSearch?: boolean;
	@Prop() containerCssClass?: string;
	@Prop() mobileShortMode!: boolean;
	@Prop() dropdownAutoWidth?: boolean;
	@Prop() changedEventDelay!: number;
	@Prop() noResultsFound: () => void;
	@Prop() customRenderOption?: (h: any, state: DropdownDisplayArgs, originator?: CustomRenderOriginator) => any;
	@Prop() customRenderSelectionResult?: (h: any, state: DropdownDisplayArgs, originator?: CustomRenderOriginator) => any;
	@Prop() customIdProperty?: string;
	@Prop() asyncLoader?: AsyncLoaderFn;
	@Prop() asyncPageSize?: number;
	@Prop() virtualizeOptions?: boolean;

	// ── Tags-mode props ──────────────────────────────────────────────────────
	@Prop() tags!: boolean;
	@Prop() tagsSortable?: boolean;
	@Prop() tagsShouldPrependContent?: boolean;
	@Prop() tagsButtons?: (item: any) => DropdownListButton[];
	@Prop() tagsAdded?: (e: DropdownListTagAddedArgs) => void;
	@Prop() tagsNewPlaceLast?: boolean;
	@Prop() trailingButton?: DropdownTrailingButtonArgs;
	@Prop() allowExclusiveSearch?: boolean;

	// ── Reactive state ────────────────────────────────────────────────────────
	isOpen: boolean = false;
	searchQuery: string = '';
	focusedFlatIndex: number = -1;
	// In multi-select we accumulate pending selection in the panel and commit
	// on "Done". For single-select we commit on item click.
	pendingMulti: string[] | null = null;
	// In tags mode the pending list is committed on every change (chip add/remove),
	// matching select2's "tags" behavior.
	tagsPending: any[] | null = null;
	currentExclusivity: MultiSelectExclusivity = MultiSelectExclusivity.Inclusive;
	// Viewport-aware drop direction. Default is downward (top:100%); set to true
	// only when there is not enough room below the trigger AND more room above,
	// so bottom-of-viewport dropdowns open upward instead of below the fold.
	openUp: boolean = false;
	/**
	 * MobileModeConfig.shouldDisplayInModal() pinned at open() time. The
	 * modal-vs-inline decision must NOT be recomputed while the panel is open:
	 * the viewport checks feeding it can flip mid-interaction (e.g. the
	 * on-screen keyboard collapsing the layout viewport), and a flip would swap
	 * the render branch under an open panel — stranding a shown Bootstrap modal
	 * or vanishing the inline panel — while close() routes down the wrong path.
	 * Cleared on close so the next open re-evaluates the current viewport.
	 */
	private activeDisplayInModal: boolean | null = null;
	private rafScrollId: number | null = null;
	private outsideMouseDownHandler: ((e: MouseEvent) => void) | null = null;
	private sortableInstance: any = null;
	private uuid: string = `pd-dd-${PortalUtils.randomString(7)}`;

	// ── Async-loader state ────────────────────────────────────────────────────
	/** Items currently visible in the panel for the active (search, page) tuple. */
	asyncList: any[] = [];
	/**
	 * Every item we've ever seen from the loader, keyed by ID. Used to resolve
	 * chip labels for selected IDs that may not be in the current page.
	 */
	asyncCache: Map<string, any> = new Map();
	asyncPage: number = 0;
	asyncHasMore: boolean = false;
	asyncLoading: boolean = false;
	asyncError: string | null = null;
	private asyncDebounceTimer: any = null;
	private asyncRequestSeq: number = 0;

	// ── Virtualization state ──────────────────────────────────────────────────
	virtualScrollTop: number = 0;
	virtualViewportHeight: number = 0;
	/**
	 * Row height in px. Defaults to 30 (matches the base option styling) and
	 * is replaced after the first rendered row is measured. The estimate is
	 * "good enough" for first-paint; the measurement narrows the gap on the
	 * second paint with no visible jump for uniform-height rows.
	 */
	measuredRowHeight: number = 30;
	private virtualScrollHandlerBound: ((e: Event) => void) | null = null;
	private rowMeasureRaf: number | null = null;

	// ── Computed-ish getters ──────────────────────────────────────────────────

	get isInlineMode(): boolean {
		return this.displayMode === 'inline';
	}

	get useModalMode(): boolean {
		// Match legacy: mobile + not in iframe + not tags-mode (chip multi-add
		// flows badly inside a bottom-sheet) + not inline.
		if (this.isInlineMode) { return false; }

		if (this.tags === true) { return false; }

		if (PortalUtils.isInIframe()) { return false; }

		return this.activeDisplayInModal ?? MobileModeConfig.shouldDisplayInModal();
	}

	get isMultiCheckboxes(): boolean {
		return this.multiselect === true && this.multiselectMode === MultiselectMode.Checkboxes;
	}

	get useDoneButton(): boolean {
		// Bottom Done/Cancel footer — used by the default multi-chip mode.
		// Checkboxes mode has its own OK button at the TOP of the panel
		// (`useOkHeader`); inline and tags modes commit immediately.
		if (this.isInlineMode) { return false; }

		if (this.isMultiCheckboxes) { return false; }

		if (this.tags === true) { return false; }

		return this.multiselect === true;
	}

	get useOkHeader(): boolean {
		// Checkboxes mode mirrors select2's `s2-multi-cb` plugin: a single OK
		// button at the top of the panel commits the pending session. Closing
		// any other way (Escape / click outside) discards. Same pending/commit
		// semantics as `useDoneButton` — just a different placement.
		return this.isMultiCheckboxes && !this.isInlineMode;
	}

	private get okButtonLabel(): string {
		return this.resolveResource('ok', 'OK');
	}

	private get fallbackAllLabel(): string {
		return this.resolveResource('all', 'All');
	}

	/**
	 * Look up a project resource and fall back to a plain string when the
	 * resource registry returns the unresolved `{{key}}` placeholder
	 * (downstream apps without the i18n bundle loaded).
	 */
	private resolveResource(key: string, fallback: string): string {
		const v = PowerduckState.getResourceValue(key as any) as any;
		if (typeof v === 'string' && v.length > 0 && !v.startsWith('{{')) { return v; }

		return fallback;
	}

	get useAllowClear(): boolean {
		// Mirror select2: allowClear defaults to ON when a placeholder is set.
		// Tags / multi-select have their own remove paths (× per chip), so the
		// trigger-level × only applies to single-select.
		if (this.multiselect === true) { return false; }

		if (this.disabled || this.blocked) { return false; }

		if (this.placeholder == null || this.placeholder.length === 0) { return false; }

		return this.getSelectedItems().length > 0;
	}

	get effectivePlaceholder(): string {
		return this.placeholder ?? '';
	}

	get isAsyncMode(): boolean {
		return typeof this.asyncLoader === 'function';
	}

	get isVirtualized(): boolean {
		return this.virtualizeOptions === true;
	}

	get effectiveAsyncPageSize(): number {
		return this.asyncPageSize != null && this.asyncPageSize > 0 ? this.asyncPageSize : 50;
	}

	// ── Option/data normalization (mirrors legacy getOptions) ─────────────────

	private getValueFromArr(paramArr: string[], row: any): string | null {
		for (const key of paramArr) {
			let val = row[key];
			if (val == null) {
				val = row[(key as any)[capitalize]()];
			}

			if (val != null) {
				if (PortalUtils.isString(val) && (val as string).length > 0) { return val as string; }

				if (PortalUtils.isNumber(val)) { return (val as number).toString(); }

				if (PortalUtils.isFunction(val)) { return (val as any).call(row, row); }
			}
		}
		return null;
	}

	private getReflectedRowValue(row: any, isValueMember: boolean): string {
		const member = isValueMember ? this.valueMember : this.displayMember;
		if (member == null) {
			if (isValueMember) {
				if (this.customIdProperty != null && this.customIdProperty.length > 0) {
					return this.getValueFromArr([this.customIdProperty], row) ?? '';
				}

				return this.getValueFromArr([
					'id',
					'uuid',
				], row) ?? '';
			}

			return this.getValueFromArr([
				'name',
				'text',
				'identifier',
			], row) ?? '';
		}

		if (PortalUtils.isString(member)) {
			return row[member as any];
		}

		if (PortalUtils.isFunction(member)) {
			return (member as any).call(row, row);
		}

		return String(row[member as any]);
	}

	private isOptionGroupBinding(): boolean {
		const firstOpt = this.options?.[0] as any;
		return firstOpt != null && firstOpt.isOptGroup === true;
	}

	getOptions(preserveOptGroups: boolean = true): DropdownDisplayArgs[] {
		if (this.isAsyncMode) {
			// In async mode the server is the authority — `options` prop is ignored.
			// preserveOptGroups is moot here since async results are a flat list.
			const _unused = preserveOptGroups;
			void _unused;
			return this.asyncList.map(row => this.rowToDisplay(row));
		}

		const opts = this.options as any;
		const retVal: DropdownDisplayArgs[] = [];
		if (opts == null || opts.length === 0) { return retVal; }

		const firstItem = opts[0];
		if (PortalUtils.isString(firstItem)) {
			for (const item of opts) { retVal.push({ id: item, text: item, dataRow: item }); }
			return retVal;
		}

		if (PortalUtils.isNumber(firstItem)) {
			for (const item of opts) { retVal.push({ id: String(item), text: String(item), dataRow: item }); }
			return retVal;
		}

		if ((firstItem as DropdownOptionGroup).isOptGroup === true) {
			(opts as DropdownOptionGroup[]).forEach((optGroup) => {
				const groupItems: DropdownDisplayArgs[] = optGroup.children.map((item: any) => ({
					id: this.getReflectedRowValue(item, true),
					text: this.getReflectedRowValue(item, false),
					dataRow: item,
				}));
				if (preserveOptGroups) {
					retVal.push({
						id: `__group__:${optGroup.text}`,
						text: optGroup.text,
						dataRow: optGroup,
						children: groupItems,
						isOptionGroup: true,
					});
				} else {
					// Flatten — used for keyboard-nav math
					retVal.push(...groupItems);
				}
			});
			return retVal;
		}

		for (const item of opts) {
			retVal.push({
				id: this.getReflectedRowValue(item, true),
				text: this.getReflectedRowValue(item, false),
				dataRow: item,
			});
		}
		return retVal;
	}

	getSelectedIds(): string[] {
		if (this.selected == null) { return []; }

		const arr = PortalUtils.isArray(this.selected) ? this.selected : [this.selected];
		return (arr as any[]).map((item) => {
			if (PortalUtils.isString(item) || PortalUtils.isNumber(item)) {
				return String(item);
			}

			return this.getReflectedRowValue(item, true);
		});
	}

	private get effectiveSelectedIds(): string[] {
		// During an open multi-select session, the pending set is authoritative.
		if (this.multiselect === true && this.pendingMulti != null) {
			return this.pendingMulti;
		}

		return this.getSelectedIds();
	}

	getSelectedItems(): DropdownDisplayArgs[] {
		const selIds = this.effectiveSelectedIds;
		if (selIds.length === 0) { return []; }

		if (this.isAsyncMode) {
			// Use the asyncCache (every row we've ever seen) to resolve chip
			// labels — even when the current page doesn't contain a given
			// selection (e.g. it was loaded for a different search).
			return selIds.map((id) => {
				const row = this.asyncCache.get(id);
				if (row != null) { return this.rowToDisplay(row); }

				// Fallback: render the raw ID until the loader returns it.
				// Loader-author contract (per AsyncLoaderArgs.selectedIds): they
				// should include this row in the first response.
				return { id, text: id, dataRow: row ?? id };
			});
		}

		const flat = this.getOptions(false);
		const byId = new Map(flat.map(o => [
			o.id,
			o,
		]));
		return selIds.map(id => byId.get(id)).filter(Boolean) as DropdownDisplayArgs[];
	}

	private rowToDisplay(row: any): DropdownDisplayArgs {
		if (PortalUtils.isString(row) || PortalUtils.isNumber(row)) {
			return { id: String(row), text: String(row), dataRow: row };
		}

		return {
			id: this.getReflectedRowValue(row, true),
			text: this.getReflectedRowValue(row, false),
			dataRow: row,
		};
	}

	// ── Search / filter ───────────────────────────────────────────────────────

	private filterTree(items: DropdownDisplayArgs[], q: string): DropdownDisplayArgs[] {
		if (q.length === 0) { return items; }

		const out: DropdownDisplayArgs[] = [];
		for (const item of items) {
			if (item.children?.length) {
				const filtered = this.filterTree(item.children, q);
				if (filtered.length > 0) { out.push({ ...item, children: filtered }); }
			} else {
				if (normalize(item.text).includes(q)) { out.push(item); }
			}
		}
		return out;
	}

	get visibleItems(): DropdownDisplayArgs[] {
		// Async mode: the server has already filtered; just show what's loaded.
		if (this.isAsyncMode) {
			return this.getOptions(true);
		}

		const opts = this.getOptions(true);
		const q = normalize(this.searchQuery);
		return this.filterTree(opts, q);
	}

	get visibleFlat(): DropdownDisplayArgs[] {
		const flat: DropdownDisplayArgs[] = [];
		for (const item of this.visibleItems) {
			if (item.isOptionGroup && item.children) {
				flat.push(...item.children);
			} else {
				flat.push(item);
			}
		}
		// Add the "Add: <typed>" virtual entry at the end so keyboard nav can
		// reach it and Enter on it triggers free-text-tag creation.
		if (this.canShowAddSuggestion) {
			flat.push({
				id: '__pd_dd_add__',
				text: this.addSuggestionText,
				dataRow: null,
				isOptionGroup: false,
				// Marker for handleItemClick / toggleItem to route through addFreeTextTag.
				_isAddSuggestion: true,
			} as any);
		}

		return flat;
	}

	get hasVisibleResults(): boolean {
		return this.visibleFlat.length > 0;
	}

	// ── Open / close / commit ─────────────────────────────────────────────────

	open(): void {
		if (this.disabled || this.blocked) { return; }

		if (this.isOpen) { return; }

		this.searchQuery = '';
		this.focusedFlatIndex = -1;
		// Tags mode commits per click via toggleTagItem (emits `changed` directly).
		// Initialising pendingMulti here would cause close()'s commit branch to
		// re-emit the pre-toggle selection and override per-click commits — QA_GO-429 Bug A.
		if (this.multiselect === true && this.tags !== true) {
			this.pendingMulti = [...this.getSelectedIds()];
		}

		this.activeDisplayInModal = this.useModalMode;
		this.isOpen = true;
		this.$nextTick(() => {
			if (this.isAsyncMode) {
				this.loadAsyncFirstPage();
			}

			// One scroll listener handles both virtualization windowing and
			// async bottom-of-list pagination. Attached after the panel mounts.
			if (this.isAsyncMode || this.isVirtualized) {
				this.attachPanelScrollHandler();
				if (this.isVirtualized) { this.scheduleRowMeasurement(); }
			}

			if (this.useModalMode) {
				(this.$refs.pdDdModal as any)?.show({
					onHidden: () => {
						this.handleClose();
					},
				});
			} else {
				this.attachOutsideHandler();
				this.focusSearch();
				this.updatePanelDirection();
			}
		});
	}

	close(commit: boolean = true): void {
		if (!this.isOpen) { return; }

		if (commit && this.multiselect === true && this.pendingMulti != null) {
			this.commitMultiPending();
		}

		if (this.useModalMode) {
			(this.$refs.pdDdModal as any)?.hide();
			return;
		}

		this.handleClose();
	}

	private handleClose(): void {
		this.isOpen = false;
		this.activeDisplayInModal = null;
		this.pendingMulti = null;
		this.searchQuery = '';
		this.focusedFlatIndex = -1;
		this.openUp = false;
		this.detachOutsideHandler();
		this.detachPanelScrollHandler();
		if (this.asyncDebounceTimer != null) {
			clearTimeout(this.asyncDebounceTimer);
			this.asyncDebounceTimer = null;
		}

		// Bump the request seq so any in-flight loader response is discarded.
		this.asyncRequestSeq++;
		this.asyncLoading = false;
		this.asyncError = null;
	}

	private commitMultiPending(): void {
		const ids = this.pendingMulti ?? [];
		const flat = this.getOptions(false);
		const byId = new Map(flat.map(o => [
			o.id,
			o,
		]));
		const data = ids.map(id => byId.get(id)?.dataRow).filter(v => v != null);
		this.changed?.(data, undefined);
	}

	private commitSingle(item: DropdownDisplayArgs | null): void {
		this.changed?.(item?.dataRow ?? null, undefined);
	}

	private attachOutsideHandler(): void {
		this.detachOutsideHandler();
		const self = this;
		this.outsideMouseDownHandler = (e: MouseEvent) => {
			const root = self.$el as HTMLElement | null;
			if (root == null) { return; }

			if (!root.contains(e.target as Node)) {
				self.close(true);
			}
		};
		document.addEventListener('mousedown', this.outsideMouseDownHandler);
	}

	private detachOutsideHandler(): void {
		if (this.outsideMouseDownHandler != null) {
			document.removeEventListener('mousedown', this.outsideMouseDownHandler);
			this.outsideMouseDownHandler = null;
		}
	}

	private focusSearch(): void {
		const input = (this.$el as HTMLElement | null)?.querySelector<HTMLInputElement>('.pd-dd-search-input');
		input?.focus();
	}

	// ── Viewport-aware drop direction ─────────────────────────────────────────
	// Additive to the default downward panel: measure the trigger against the
	// viewport and open upward only when there is not enough room below AND more
	// room above. When there is room below, `openUp` stays false and the panel
	// renders byte-for-byte as before (`.pd-dd-panel { top: 100% }`).
	private updatePanelDirection(): void {
		// Inline mode renders a static panel with no absolute positioning; the
		// flip must be a no-op there.
		if (this.isInlineMode) { return; }

		const root = this.$el as HTMLElement | null;
		const trigger = root?.querySelector<HTMLElement>('.pd-dd-trigger');
		const panel = root?.querySelector<HTMLElement>('.pd-dd-panel');
		if (trigger == null || panel == null) { return; }

		const rect = trigger.getBoundingClientRect();
		// Clamp to a sensible cap so async/virtualized panels (height 0 at first
		// measure) still make a stable decision.
		const panelHeight = Math.min(panel.offsetHeight || 0, 360);
		const spaceBelow = globalState.innerHeight - rect.bottom;
		const spaceAbove = rect.top;

		this.openUp = spaceBelow < panelHeight && spaceAbove > spaceBelow;
	}

	// ── Item interaction ──────────────────────────────────────────────────────

	private toggleItem(item: DropdownDisplayArgs): void {
		if (this.tags === true) {
			// Route the virtual "Add: <typed>" suggestion through the free-text
			// add pipeline; everything else toggles selection.
			if ((item as any)._isAddSuggestion === true) {
				this.addFreeTextTag(this.searchQuery);
				return;
			}

			// In tags mode each click on an existing option toggles its
			// presence in the immediately-committed selection.
			this.toggleTagItem(item);
			return;
		}

		if (this.multiselect !== true) {
			this.commitSingle(item);
			if (this.closeOnSelect !== false && !this.isInlineMode) {
				this.close(false);
			}

			return;
		}

		// Inline mode commits immediately (no Done/OK button — every toggle is
		// authoritative). Multi-chip and Checkboxes modes both accumulate into
		// `pendingMulti` and commit on Done / OK respectively.
		if (this.isInlineMode) {
			const currentIds = this.getSelectedIds();
			const idx = currentIds.indexOf(item.id);
			const nextIds = idx >= 0
				? currentIds.filter(id => id !== item.id)
				: [
						...currentIds,
						item.id,
					];
			const flat = this.getOptions(false);
			const byId = new Map(flat.map(o => [
				o.id,
				o,
			]));
			const data = nextIds.map(id => byId.get(id)?.dataRow).filter(v => v != null);
			this.changed?.(data, undefined);
			return;
		}

		const ids = this.pendingMulti ?? [...this.getSelectedIds()];
		const idx = ids.indexOf(item.id);
		if (idx >= 0) {
			ids.splice(idx, 1);
		} else {
			ids.push(item.id);
		}

		this.pendingMulti = ids;
	}

	// ── Tags mode ─────────────────────────────────────────────────────────────

	private getSelectedTagRows(): any[] {
		// In tags mode `selected` is always an array of domain rows.
		if (this.selected == null) { return []; }

		return PortalUtils.isArray(this.selected) ? (this.selected as any[]) : [this.selected];
	}

	private toggleTagItem(item: DropdownDisplayArgs): void {
		// Select-by-click semantics in tags mode: add to selection if missing,
		// remove if already present. Same as a multi-select but committed now.
		const current = this.getSelectedTagRows();
		const existingIdx = current.findIndex((r) => {
			const id = (r != null && PortalUtils.isString(r) === false && PortalUtils.isNumber(r) === false)
				? this.getReflectedRowValue(r, true)
				: String(r);
			return id === item.id;
		});

		let next: any[];
		if (existingIdx >= 0) {
			next = current.slice();
			next.splice(existingIdx, 1);
		} else {
			next = this.tagsNewPlaceLast !== false
				? [
						...current,
						item.dataRow,
					]
				: [
						item.dataRow,
						...current,
					];
		}

		this.changed?.(next, this.currentExclusivity);

		// Clear search and keep focus in the tag input
		this.searchQuery = '';
		this.focusedFlatIndex = -1;
		this.$nextTick(() => this.focusTagInput());
	}

	private removeTagAt(index: number): void {
		const current = this.getSelectedTagRows();
		if (index < 0 || index >= current.length) { return; }

		const next = current.slice();
		next.splice(index, 1);
		this.changed?.(next, this.currentExclusivity);
	}

	private addFreeTextTag(raw: string): void {
		const trimmed = (raw || '').trim();
		if (trimmed.length === 0) { return; }

		// If trimmed already matches an existing option (case-insensitive), select
		// it instead of adding a duplicate free-text tag.
		const normQ = normalize(trimmed);
		const flat = this.getOptions(false);
		const match = flat.find(o => normalize(o.text) === normQ);
		if (match != null) {
			this.toggleTagItem(match);
			return;
		}

		const eventArgs: DropdownListTagAddedArgs = {
			closeSelection: false,
			tagArr: [trimmed],
		};

		if (this.tagsAdded != null) {
			this.tagsAdded(eventArgs);
		} else {
			// No callback registered → append the bare string to the selection,
			// matching select2's default "tags: true" behaviour.
			const current = this.getSelectedTagRows();
			const next = this.tagsNewPlaceLast !== false
				? [
						...current,
						trimmed,
					]
				: [
						trimmed,
						...current,
					];
			this.changed?.(next, this.currentExclusivity);
		}

		this.searchQuery = '';
		this.focusedFlatIndex = -1;

		if (eventArgs.closeSelection) {
			this.close(false);
		} else {
			this.$nextTick(() => this.focusTagInput());
		}
	}

	private focusTagInput(): void {
		const inp = (this.$el as HTMLElement | null)?.querySelector<HTMLInputElement>('.pd-dd-tag-input');
		inp?.focus();
	}

	private handleTagInputKeyDown(e: KeyboardEvent): void {
		switch (e.key) {
			case 'Enter': {
				e.preventDefault();
				// If an option is focused, take it. Otherwise turn the search into
				// a new tag (free-text add).
				if (this.focusedFlatIndex >= 0 && this.focusedFlatIndex < this.visibleFlat.length) {
					this.toggleItem(this.visibleFlat[this.focusedFlatIndex]);
				} else if (this.searchQuery.trim().length > 0) {
					this.addFreeTextTag(this.searchQuery);
				}

				break;
			}
			case 'Backspace': {
				if (this.searchQuery.length === 0) {
					const sel = this.getSelectedTagRows();
					if (sel.length > 0) {
						this.removeTagAt(sel.length - 1);
					}
				}

				break;
			}
			case 'ArrowDown':
				e.preventDefault();
				if (!this.isOpen) {
					this.open();
				} else {
					this.focusedFlatIndex = Math.min(this.visibleFlat.length - 1, this.focusedFlatIndex + 1);
					this.scrollFocusedIntoView();
				}

				break;
			case 'ArrowUp':
				e.preventDefault();
				this.focusedFlatIndex = Math.max(-1, this.focusedFlatIndex - 1);
				this.scrollFocusedIntoView();
				break;
			case 'Escape':
				e.preventDefault();
				this.close(false);
				break;
			case 'Tab':
				this.close(false);
				break;
		}
	}

	private bindSortable(): void {
		if (this.tags !== true || this.tagsSortable !== true) { return; }

		const list = (this.$el as HTMLElement | null)?.querySelector<HTMLElement>('.pd-dd-tags-chips');
		if (list == null) { return; }

		// Tear down any prior instance.
		this.destroySortable();
		this.sortableInstance = new (Sortable as any)(list, {
			animation: 150,
			handle: '.pd-dd-chip.pd-dd-tag-chip',
			draggable: '.pd-dd-tag-chip',
			onEnd: (evt: any) => {
				const oldIndex = evt.oldIndex;
				let newIndex = evt.newIndex;
				if (newIndex > oldIndex) { newIndex -= 1; }

				const current = this.getSelectedTagRows();
				if (current == null || current[oldIndex] == null) { return; }

				const next = [...current];
				const removed = next.splice(oldIndex, 1);
				next.splice(
					newIndex,
					0,
					removed[0],
				);
				this.changed?.(next, this.currentExclusivity);
			},
		});
	}

	private destroySortable(): void {
		if (this.sortableInstance != null) {
			try { this.sortableInstance.destroy(); } catch { }
			this.sortableInstance = null;
		}
	}

	private handleItemClick(item: DropdownDisplayArgs, e: MouseEvent): void {
		e.preventDefault();
		e.stopPropagation();
		if (item.isOptionGroup) { return; } // headers are not selectable

		this.toggleItem(item);
	}

	private isItemSelected(item: DropdownDisplayArgs): boolean {
		return this.effectiveSelectedIds.includes(item.id);
	}

	// ── Keyboard nav ──────────────────────────────────────────────────────────

	private handleSearchKeyDown(e: KeyboardEvent): void {
		switch (e.key) {
			case 'ArrowDown':
				e.preventDefault();
				this.focusedFlatIndex = Math.min(this.visibleFlat.length - 1, this.focusedFlatIndex + 1);
				this.scrollFocusedIntoView();
				break;
			case 'ArrowUp':
				e.preventDefault();
				this.focusedFlatIndex = Math.max(0, this.focusedFlatIndex - 1);
				this.scrollFocusedIntoView();
				break;
			case 'Home':
				e.preventDefault();
				this.focusedFlatIndex = 0;
				this.scrollFocusedIntoView();
				break;
			case 'End':
				e.preventDefault();
				this.focusedFlatIndex = this.visibleFlat.length - 1;
				this.scrollFocusedIntoView();
				break;
			case 'Enter':
				e.preventDefault();
				if (this.focusedFlatIndex >= 0 && this.focusedFlatIndex < this.visibleFlat.length) {
					this.toggleItem(this.visibleFlat[this.focusedFlatIndex]);
				} else if (this.visibleFlat.length === 1) {
					// Auto-pick when query narrows results to exactly one match.
					this.toggleItem(this.visibleFlat[0]);
				}

				break;
			case 'Escape':
				e.preventDefault();
				this.close(false);
				break;
			case 'Tab':
				this.close(true);
				break;
		}
	}

	// ── Async loader ──────────────────────────────────────────────────────────

	private async loadAsyncFirstPage(): Promise<void> {
		if (this.asyncLoader == null) { return; }

		this.asyncList = [];
		this.asyncPage = 0;
		this.asyncHasMore = false;
		await this.runAsyncLoader(0, this.getSelectedIds());
	}

	private async loadAsyncNextPage(): Promise<void> {
		if (this.asyncLoader == null) { return; }

		if (this.asyncLoading || !this.asyncHasMore) { return; }

		await this.runAsyncLoader(this.asyncPage + 1, []);
	}

	private async runAsyncLoader(page: number, selectedIdsForRequest: string[]): Promise<void> {
		if (this.asyncLoader == null) { return; }

		this.asyncLoading = true;
		this.asyncError = null;
		const seq = ++this.asyncRequestSeq;
		try {
			const res = await this.asyncLoader({
				selectedIds: selectedIdsForRequest,
				searchQuery: this.searchQuery,
				limit: this.effectiveAsyncPageSize,
				page,
			});
			// Discard if a newer request superseded us (or component closed).
			if (seq !== this.asyncRequestSeq) { return; }

			const items = Array.isArray(res?.items) ? res.items : [];
			if (page === 0) {
				this.asyncList = items.slice();
			} else {
				this.asyncList = [
					...this.asyncList,
					...items,
				];
			}

			for (const row of items) {
				const id = this.rowToDisplay(row).id;
				if (id != null && id.length > 0) { this.asyncCache.set(id, row); }
			}
			this.asyncPage = page;
			this.asyncHasMore = res?.hasMore === true;
		} catch (err: any) {
			if (seq !== this.asyncRequestSeq) { return; }

			this.asyncError = err?.message ?? String(err);
		} finally {
			if (seq === this.asyncRequestSeq) { this.asyncLoading = false; }
		}
	}

	private scheduleAsyncSearch(): void {
		if (this.asyncLoader == null) { return; }

		if (this.asyncDebounceTimer != null) { clearTimeout(this.asyncDebounceTimer); }

		this.asyncDebounceTimer = setTimeout(() => {
			this.asyncDebounceTimer = null;
			this.loadAsyncFirstPage();
		}, 300);
	}

	// ── Virtualization ────────────────────────────────────────────────────────

	/**
	 * Unified scroll handler for the option panel: updates virtualization
	 * windowing state AND drives async pagination. Bound to one specific
	 * scroller element on open, removed on close. Re-renders of the panel do
	 * not replace the wrapper element so the binding stays valid.
	 */
	private attachPanelScrollHandler(): void {
		this.detachPanelScrollHandler();
		const root = this.$el as HTMLElement | null;
		const scroller = root?.querySelector<HTMLElement>('.pd-dd-results');
		if (scroller == null) { return; }

		this.virtualViewportHeight = scroller.clientHeight;
		this.virtualScrollTop = scroller.scrollTop;
		const self = this;
		this.virtualScrollHandlerBound = (e: Event) => {
			const el = e.target as HTMLElement;
			if (self.isVirtualized) {
				self.virtualScrollTop = el.scrollTop;
				self.virtualViewportHeight = el.clientHeight;
			}

			if (self.isAsyncMode && self.asyncHasMore && !self.asyncLoading) {
				const remaining = el.scrollHeight - (el.scrollTop + el.clientHeight);
				if (remaining < 100) { self.loadAsyncNextPage(); }
			}
		};
		scroller.addEventListener(
			'scroll',
			this.virtualScrollHandlerBound,
			{ passive: true },
		);
	}

	private detachPanelScrollHandler(): void {
		if (this.virtualScrollHandlerBound != null) {
			const root = this.$el as HTMLElement | null;
			const scroller = root?.querySelector<HTMLElement>('.pd-dd-results');
			scroller?.removeEventListener('scroll', this.virtualScrollHandlerBound);
			this.virtualScrollHandlerBound = null;
		}
	}

	private scheduleRowMeasurement(): void {
		if (this.rowMeasureRaf != null) { return; }

		this.rowMeasureRaf = globalState.requestAnimationFrame(() => {
			this.rowMeasureRaf = null;
			const root = this.$el as HTMLElement | null;
			const firstRow = root?.querySelector<HTMLElement>('.pd-dd-results-list .pd-dd-option');
			if (firstRow != null) {
				const h = firstRow.getBoundingClientRect().height;
				if (h > 0 && h !== this.measuredRowHeight) {
					this.measuredRowHeight = h;
				}
			}
		});
	}

	private scrollFocusedIntoView(): void {
		if (this.rafScrollId != null) { return; }

		this.rafScrollId = globalState.requestAnimationFrame(() => {
			this.rafScrollId = null;
			const root = this.$el as HTMLElement | null;
			const focused = root?.querySelector<HTMLElement>(`#${this.uuid}-opt-${this.focusedFlatIndex}`);
			focused?.scrollIntoView({ block: 'nearest' });
		});
	}

	// ── Lifecycle ─────────────────────────────────────────────────────────────

	mounted(): void {
		this.bindSortable();
		// In async mode, eagerly resolve labels for pre-selected IDs so chips
		// render with meaningful text immediately rather than as bare IDs.
		if (this.isAsyncMode) {
			const selIds = this.getSelectedIds();
			if (selIds.length > 0) {
				this.runAsyncLoader(0, selIds);
			}
		}
	}

	updated(): void {
		this.bindSortable();
	}

	beforeUnmount(): void {
		this.detachOutsideHandler();
		this.destroySortable();
		this.detachPanelScrollHandler();
		if (this.asyncDebounceTimer != null) {
			clearTimeout(this.asyncDebounceTimer);
			this.asyncDebounceTimer = null;
		}

		if (this.rafScrollId != null) {
			globalState.cancelAnimationFrame(this.rafScrollId);
			this.rafScrollId = null;
		}

		if (this.rowMeasureRaf != null) {
			globalState.cancelAnimationFrame(this.rowMeasureRaf);
			this.rowMeasureRaf = null;
		}

		// Invalidate any in-flight loader response.
		this.asyncRequestSeq++;
	}

	// ── Custom render plumbing ────────────────────────────────────────────────

	private renderCustom(
		fn: (h: any, state: DropdownDisplayArgs, originator?: CustomRenderOriginator) => any,
		item: DropdownDisplayArgs,
		originator: CustomRenderOriginator,
	): any {
		const out = fn(
			null,
			item,
			originator,
		);
		if (VueUtils.isVNode(out)) {
			// Vue can render VNodes returned from a render function directly —
			// no need to imperatively render them into a detached host first.
			return out;
		}

		if (typeof out === 'string') {
			return <span innerHTML={out}></span>;
		}

		return out ?? <span>{item.text}</span>;
	}

	// ── Render: trigger ───────────────────────────────────────────────────────

	private renderTriggerLabel(): VNode {
		const selectedItems = this.getSelectedItems();
		const isMulti = this.multiselect === true;

		// Multi-select chip mode (NOT Checkboxes): chips inside the trigger,
		// each with its own × remove button.
		if (isMulti && !this.isMultiCheckboxes) {
			if (selectedItems.length === 0) {
				return <span class="pd-dd-trigger-text pd-dd-placeholder">{this.effectivePlaceholder}</span>;
			}

			return (
				<span class="pd-dd-chips">
					{selectedItems.map((item, idx) => (
						<span class="pd-dd-chip" title={item.text}>
							<span class="pd-dd-chip-text">{this.renderItemContent(item, 'default')}</span>
							<span
								class="pd-dd-chip-remove"
								role="button"
								tabindex={0}
								aria-label={`Remove ${item.text}`}
								onMousedown={(e: MouseEvent) => {
									// Don't let the chip click bubble to the trigger
									// (which would re-toggle the dropdown).
									e.preventDefault();
									e.stopPropagation();
								}}
								onClick={(e: MouseEvent) => {
									e.preventDefault();
									e.stopPropagation();
									this.removeMultiChipAt(idx);
								}}
								onKeydown={(e: KeyboardEvent) => {
									if (e.key === 'Enter' || e.key === ' ') {
										e.preventDefault();
										e.stopPropagation();
										this.removeMultiChipAt(idx);
									}
								}}
							>
								×
							</span>
						</span>
					))}
				</span>
			);
		}

		// Single-select / Checkboxes: show selection text or placeholder, plus
		// a × clear button when placeholder is set and a value is selected.
		if (selectedItems.length === 0) {
			// Checkboxes mode mirrors legacy s2-multi-cb: placeholder if set,
			// otherwise fall back to the project's `all` resource ("All" in
			// English) — single-select keeps the existing empty-placeholder
			// behaviour to stay backward-compatible.
			if (this.isMultiCheckboxes) {
				const emptyText = !isNullOrEmpty(this.placeholder) ? this.placeholder : this.fallbackAllLabel;
				return <span class="pd-dd-trigger-text pd-dd-placeholder">{emptyText}</span>;
			}

			return <span class="pd-dd-trigger-text pd-dd-placeholder">{this.effectivePlaceholder}</span>;
		}

		const renderText = (): any => {
			if (this.customRenderSelectionResult != null) {
				return this.renderCustom(
					this.customRenderSelectionResult,
					selectedItems[0],
					this.useModalMode ? 'mobile' : 'default',
				);
			}

			const text = selectedItems.length === 1
				? selectedItems[0].text
				: `${selectedItems.length} ${this.resolveResource('selected', 'selected')}`;
			return text;
		};

		const text = selectedItems.length === 1
			? selectedItems[0].text
			: '';

		return (
			<span class="pd-dd-trigger-row" style="display: contents;">
				<span class="pd-dd-trigger-text" title={text}>{renderText()}</span>
				{this.useAllowClear && (
					<span
						class="pd-dd-trigger-clear"
						role="button"
						tabindex={0}
						aria-label="Clear selection"
						title="Clear"
						onMousedown={(e: MouseEvent) => {
							e.preventDefault();
							e.stopPropagation();
						}}
						onClick={(e: MouseEvent) => {
							e.preventDefault();
							e.stopPropagation();
							this.clearSelection();
						}}
						onKeydown={(e: KeyboardEvent) => {
							if (e.key === 'Enter' || e.key === ' ') {
								e.preventDefault();
								e.stopPropagation();
								this.clearSelection();
							}
						}}
					>
						×
					</span>
				)}
			</span>
		);
	}

	private removeMultiChipAt(index: number): void {
		const currentIds = this.getSelectedIds();
		if (index < 0 || index >= currentIds.length) { return; }

		const nextIds = currentIds.filter((_, i) => i !== index);
		const flat = this.getOptions(false);
		const byId = new Map(flat.map(o => [
			o.id,
			o,
		]));
		const data = nextIds.map(id => byId.get(id)?.dataRow).filter(v => v != null);
		this.changed?.(data, this.currentExclusivity);
	}

	private clearSelection(): void {
		// For single-select; multi has per-chip removal.
		this.changed?.(null, this.currentExclusivity);
	}

	private renderTrigger(): VNode {
		if (this.tags === true) {
			return this.renderTagsTrigger();
		}

		const isInline = this.isInlineMode;
		const isMulti = this.multiselect === true;
		const triggerClass = [
			'pd-dd-trigger',
			isMulti ? 'pd-dd-trigger--multiple' : 'pd-dd-trigger--single',
			this.disabled ? 'pd-dd-disabled' : '',
		].filter(Boolean).join(' ');

		const rootClass = [
			'pd-dd-root',
			isInline ? 'pd-dd-inline' : '',
			isInline && this.containerCssClass ? this.containerCssClass : '',
		].filter(Boolean).join(' ');

		// In inline mode the panel is always rendered alongside the trigger,
		// and the trigger itself doesn't toggle anything (clicking it is a
		// no-op — the panel never closes).
		const panel = (isInline || (!this.useModalMode && this.isOpen))
			? this.renderInlinePanel()
			: null;

		const activeDescendantId = this.focusedOptionDomId;
		const listboxId = `${this.uuid}-results`;

		return (
			<div
				class={rootClass}
				dir="ltr"
				ref="root"
			>
				<LoadingIndicator visible={this.blocked === true} />
				<span class="selection">
					<span
						class={triggerClass}
						role="combobox"
						aria-haspopup="listbox"
						aria-expanded={(isInline || this.isOpen) ? 'true' : 'false'}
						aria-controls={(isInline || this.isOpen) ? listboxId : undefined}
						aria-activedescendant={activeDescendantId}
						aria-disabled={this.disabled ? 'true' : 'false'}
						aria-label={this.placeholder || undefined}
						tabindex={this.disabled ? -1 : 0}
						onClick={(e: MouseEvent) => {
							e.preventDefault();
							if (this.disabled || isInline) { return; }

							if (this.isOpen) {
								this.close(true);
							} else { this.open(); }
						}}
						onKeydown={(e: KeyboardEvent) => {
							if (this.disabled || isInline) { return; }

							if (!this.isOpen) {
								if (e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowDown') {
									e.preventDefault();
									this.open();
								}

								return;
							}

							// Open + no search input (disableSearch): the trigger keeps
							// focus, so it must own arrow/Home/End/Enter/Escape nav —
							// otherwise the panel opens but arrow keys do nothing (WCAG 2.1.1).
							if (this.disableSearch === true) {
								this.handleSearchKeyDown(e);

								return;
							}

							// Search input normally has focus; if focus is still on the
							// trigger, Escape must still close the open panel.
							if (e.key === 'Escape') {
								e.preventDefault();
								this.close(false);
							}
						}}
					>
						{this.renderTriggerLabel()}
						<span class="pd-dd-trigger-arrow" aria-hidden="true">
							<b aria-hidden="true"></b>
						</span>
					</span>
				</span>
				{panel}
			</div>
		);
	}

	private get focusedOptionDomId(): string | undefined {
		if (!this.isOpen && !this.isInlineMode) { return undefined; }

		if (this.focusedFlatIndex < 0) { return undefined; }

		const addIdx = this.canShowAddSuggestion ? this.visibleFlat.length - 1 : -1;
		if (addIdx >= 0 && this.focusedFlatIndex === addIdx) {
			return `${this.uuid}-opt-add`;
		}

		return `${this.uuid}-opt-${this.focusedFlatIndex}`;
	}

	private renderTagsTrigger(): VNode {
		const chips = this.getSelectedTagRows().map((row, i) => {
			const id = (PortalUtils.isString(row) || PortalUtils.isNumber(row))
				? String(row)
				: this.getReflectedRowValue(row, true);
			const text = (PortalUtils.isString(row) || PortalUtils.isNumber(row))
				? String(row)
				: this.getReflectedRowValue(row, false);

			const buttons = this.tagsButtons != null ? this.tagsButtons(row) : [];

			const renderButton = (btn: DropdownListButton, idx: number) => (
				<span
					class={`pd-dd-chip-action dll-clickable-button ${btn.cssClass ?? ''}`}
					data-index={idx}
					role="button"
					tabindex={0}
					aria-label="Edit"
					onMousedown={(e: MouseEvent) => {
						// Prevent the chip-area click from also opening the dropdown
						e.preventDefault();
						e.stopPropagation();
					}}
					onClick={(e: MouseEvent) => {
						e.preventDefault();
						e.stopPropagation();
						btn.clicked({ item: row });
					}}
					onKeydown={(e: KeyboardEvent) => {
						if (e.key === 'Enter' || e.key === ' ') {
							e.preventDefault();
							e.stopPropagation();
							btn.clicked({ item: row });
						}
					}}
				>
					<i class={btn.iconCss} aria-hidden="true"></i>
				</span>
			);

			const renderText = () => {
				if (this.customRenderSelectionResult != null) {
					return (
						<span class="pd-dd-chip-text">
							{this.renderCustom(
								this.customRenderSelectionResult,
								{ id, text, dataRow: row },
								'default',
							)}
						</span>
					);
				}

				return <span class="pd-dd-chip-text">{text}</span>;
			};

			return (
				<li
					class="pd-dd-chip pd-dd-tag-chip"
					data-tag-index={i}
					title={text}
				>
					{this.tagsShouldPrependContent === true && buttons.map(renderButton)}
					{renderText()}
					{this.tagsShouldPrependContent !== true && buttons.map(renderButton)}
					<span
						class="pd-dd-chip-remove"
						role="button"
						tabindex={0}
						aria-label={`Remove ${text}`}
						onClick={(e: MouseEvent) => {
							e.preventDefault();
							e.stopPropagation();
							this.removeTagAt(i);
						}}
						onKeydown={(e: KeyboardEvent) => {
							if (e.key === 'Enter' || e.key === ' ') {
								e.preventDefault();
								e.stopPropagation();
								this.removeTagAt(i);
							}
						}}
					>
						×
					</span>
				</li>
			);
		});

		const showPlaceholder = chips.length === 0 && this.searchQuery.length === 0 && !isNullOrEmpty(this.placeholder);

		const listboxId = `${this.uuid}-results`;
		const activeDescendantId = this.focusedOptionDomId;

		return (
			<div
				class="pd-dd-root pd-dd-tags-root"
				dir="ltr"
				ref="root"
			>
				<LoadingIndicator visible={this.blocked === true} />
				<span class="pd-dd-selection">
					<span
						class={`pd-dd-trigger pd-dd-tags-trigger pd-dd-trigger--multiple ${this.disabled ? 'pd-dd-disabled' : ''}`}
						aria-disabled={this.disabled ? 'true' : 'false'}
						onClick={(e: MouseEvent) => {
							// Don't steal focus when a chip action was clicked
							const target = e.target as HTMLElement | null;
							if (target?.closest('.pd-dd-chip-action, .pd-dd-chip-remove')) { return; }

							if (this.disabled) { return; }

							this.focusTagInput();
							if (!this.isOpen) { this.open(); }
						}}
					>
						<ul class="pd-dd-tags-chips" role="list">
							{chips}
							<li class="pd-dd-tag-input-host">
								<input
									type="text"
									class="pd-dd-tag-input"
									value={this.searchQuery}
									disabled={this.disabled}
									placeholder={showPlaceholder ? this.placeholder : ''}
									autocomplete="off"
									autocorrect="off"
									spellcheck="false"
									role="combobox"
									aria-autocomplete="list"
									aria-haspopup="listbox"
									aria-expanded={this.isOpen ? 'true' : 'false'}
									aria-controls={this.isOpen ? listboxId : undefined}
									aria-activedescendant={activeDescendantId}
									aria-label={this.placeholder || 'Tags'}
									onInput={(e: Event) => {
										this.searchQuery = (e.target as HTMLInputElement).value;
										this.focusedFlatIndex = -1;
										if (!this.isOpen) { this.open(); }

										if (this.isAsyncMode) { this.scheduleAsyncSearch(); }
									}}
									onFocus={() => {
										if (!this.isOpen) { this.open(); }
									}}
									onKeydown={(e: KeyboardEvent) => this.handleTagInputKeyDown(e)}
								/>
							</li>
						</ul>
					</span>
				</span>
				{!this.useModalMode && this.isOpen && this.renderInlinePanel()}
			</div>
		);
	}

	// ── Render: panel ─────────────────────────────────────────────────────────

	private renderInlinePanel(): VNode {
		const panelClass = [
			'pd-dd-panel',
			this.isInlineMode ? 'pd-dd-panel-inline' : '',
			this.openUp ? 'pd-dd-open-up' : '',
			this.containerCssClass ?? '',
		].filter(Boolean).join(' ');

		return (
			<div class={panelClass}>
				{this.renderPanelContent('default')}
			</div>
		);
	}

	private renderPanelContent(originator: CustomRenderOriginator): VNode {
		// When to render a search input INSIDE the panel:
		// - Tags mode: never — the chip-area input already drives `searchQuery`
		//   and filters the option list (matches select2's tags-mode hack of
		//   reusing the chip input as the search input).
		// - Inline mode: never — matches select2's inline configuration which
		//   uses minimumResultsForSearch=Infinity.
		// - disableSearch={true}: never.
		// - Otherwise: yes.
		const showSearch = !this.isInlineMode && this.tags !== true && this.disableSearch !== true;
		return (
			<div class="pd-dd-panel-inner">
				{this.useOkHeader && (
					<div class="pd-dd-header">
						<button
							type="button"
							class="btn btn-success btn-sm pd-dd-ok-btn"
							onClick={(e: MouseEvent) => { e.preventDefault(); this.close(true); }}
						>
							{this.okButtonLabel}
						</button>
					</div>
				)}
				{showSearch && (
					<div class="pd-dd-search">
						<input
							type="search"
							class="pd-dd-search-input"
							autocomplete="off"
							autocorrect="off"
							spellcheck="false"
							role="combobox"
							aria-autocomplete="list"
							aria-haspopup="listbox"
							aria-expanded="true"
							aria-controls={`${this.uuid}-results`}
							aria-activedescendant={this.focusedOptionDomId}
							aria-label={this.placeholder || PowerduckState.getResourceValue('search')}
							placeholder={originator === 'mobile' ? PowerduckState.getResourceValue('search') : undefined}
							value={this.searchQuery}
							onInput={(e: Event) => {
								this.searchQuery = (e.target as HTMLInputElement).value;
								this.focusedFlatIndex = -1;
								if (this.isAsyncMode) { this.scheduleAsyncSearch(); }
							}}
							onKeydown={(e: KeyboardEvent) => this.handleSearchKeyDown(e)}
						/>
					</div>
				)}

				<div
					id={`${this.uuid}-results`}
					class="pd-dd-results"
					role="listbox"
					aria-multiselectable={this.multiselect ? 'true' : 'false'}
				>
					{this.isVirtualized
						? this.renderVirtualizedList(originator)
						: <ul class="pd-dd-results-list">{this.renderResultsList(originator)}</ul>}
				</div>

				{this.useDoneButton && (
					<div class="pd-dd-footer">
						<button
							type="button"
							class="btn btn-secondary btn-sm pd-dd-cancel-btn"
							onClick={(e: MouseEvent) => { e.preventDefault(); this.close(false); }}
						>
							{this.resolveResource('cancel', 'Cancel')}
						</button>
						<button
							type="button"
							class="btn btn-primary btn-sm pd-dd-done-btn"
							onClick={(e: MouseEvent) => { e.preventDefault(); this.close(true); }}
						>
							{this.resolveResource('ok', 'OK')}
						</button>
					</div>
				)}
			</div>
		);
	}

	private get canShowAddSuggestion(): boolean {
		if (this.tags !== true) { return false; }

		const q = this.searchQuery.trim();
		if (q.length === 0) { return false; }

		// Only show if the typed value isn't already an existing option (case- and
		// diacritic-insensitive). If it is, the option itself is in the list.
		const normQ = normalize(q);
		const flat = this.getOptions(false);
		return !flat.some(o => normalize(o.text) === normQ);
	}

	private get addSuggestionText(): string {
		const suffix = this.resolveResource('add', 'add');
		return `${this.searchQuery.trim()} (${suffix})`;
	}

	private renderResultsList(originator: CustomRenderOriginator): VNode[] {
		const items = this.visibleItems;
		const addSuggestion = this.canShowAddSuggestion;

		if (items.length === 0 && !addSuggestion) {
			if (this.isAsyncMode && this.asyncLoading) {
				return [
					<li class="pd-dd-loading" role="status" aria-live="polite">
						{this.resolveResource('loading', 'Loading…')}
					</li>,
				];
			}

			if (this.isAsyncMode && this.asyncError != null) {
				return [<li class="pd-dd-error" role="alert">{this.asyncError}</li>];
			}

			return [
				<li class="pd-dd-no-results">
					{this.resolveResource('noResultsFound', 'No results found')}
				</li>,
			];
		}

		const nodes: VNode[] = [];
		let flatIndex = 0;

		for (const item of items) {
			if (item.isOptionGroup && item.children) {
				// `role="group"` (with aria-label) is the semantic match for an
				// optgroup-style heading inside a listbox.
				const header = (
					<li class="pd-dd-group-header" role="group" aria-label={item.text}>
						{item.text}
					</li>
				);
				nodes.push(header);
				for (const child of item.children) {
					nodes.push(this.renderOptionItem(
						child,
						flatIndex,
						originator,
					));
					flatIndex++;
				}
			} else {
				nodes.push(this.renderOptionItem(
					item,
					flatIndex,
					originator,
				));
				flatIndex++;
			}
		}

		// In tags mode, append a virtual "Add: <typed>" suggestion as the last
		// option. Clicking it (or pressing Enter when focused) creates the new
		// free-text tag via the existing addFreeTextTag pipeline.
		if (addSuggestion) {
			const focused = flatIndex === this.focusedFlatIndex;
			const cls = [
				'pd-dd-option',
				'pd-dd-option-add',
				focused ? 'pd-dd-option--focused' : '',
			].filter(Boolean).join(' ');
			const query = this.searchQuery.trim();
			const addRow = (
				<li
					id={`${this.uuid}-opt-add`}
					class={cls}
					role="option"
					aria-selected="false"
					onMousedown={(e: MouseEvent) => e.preventDefault()}
					onClick={(e: MouseEvent) => {
						e.preventDefault();
						e.stopPropagation();
						this.addFreeTextTag(query);
					}}
					onMouseenter={() => { this.focusedFlatIndex = flatIndex; }}
				>
					<span class="pd-dd-option-text">{this.addSuggestionText}</span>
				</li>
			);
			nodes.push(addRow);
		}

		// Async: while loading more pages, append a non-interactive spinner row.
		// `role="status" aria-live="polite"` makes screen readers announce the
		// state change without stealing focus.
		if (this.isAsyncMode && this.asyncLoading && items.length > 0) {
			const loadingRow = (
				<li class="pd-dd-loading" role="status" aria-live="polite">
					<LoadingIndicator visible={true} ensureMinHeight={true} />
				</li>
			);
			nodes.push(loadingRow);
		}

		return nodes;
	}

	/**
	 * Build the ordered list of row descriptors that the panel will render.
	 * Shared by the non-virtualized and virtualized paths — virtualized skips
	 * the VNode construction for descriptors outside the visible window.
	 */
	private buildResultDescriptors(): ResultRowDescriptor[] {
		const items = this.visibleItems;
		const addSuggestion = this.canShowAddSuggestion;
		const rows: ResultRowDescriptor[] = [];

		if (items.length === 0 && !addSuggestion) {
			if (this.isAsyncMode && this.asyncLoading) {
				rows.push({ kind: 'loading' });
				return rows;
			}

			if (this.isAsyncMode && this.asyncError != null) {
				rows.push({ kind: 'error', msg: this.asyncError });
				return rows;
			}

			rows.push({ kind: 'no-results' });
			return rows;
		}

		let flatIndex = 0;
		for (const item of items) {
			if (item.isOptionGroup && item.children) {
				rows.push({ kind: 'group', item });
				for (const child of item.children) {
					rows.push({ kind: 'option', item: child, flatIndex });
					flatIndex++;
				}
			} else {
				rows.push({ kind: 'option', item, flatIndex });
				flatIndex++;
			}
		}

		if (addSuggestion) {
			rows.push({ kind: 'add', flatIndex });
		}

		if (this.isAsyncMode && this.asyncLoading && items.length > 0) {
			rows.push({ kind: 'loading' });
		}

		return rows;
	}

	private renderRowDescriptor(row: ResultRowDescriptor, originator: CustomRenderOriginator): VNode {
		switch (row.kind) {
			case 'group':
				return (
					<li class="pd-dd-group-header" role="group" aria-label={row.item.text}>
						{row.item.text}
					</li>
				);
			case 'option':
				return this.renderOptionItem(
					row.item,
					row.flatIndex,
					originator,
				);
			case 'add': {
				const focused = row.flatIndex === this.focusedFlatIndex;
				const cls = [
					'pd-dd-option',
					'pd-dd-option-add',
					focused ? 'pd-dd-option--focused' : '',
				].filter(Boolean).join(' ');
				const query = this.searchQuery.trim();
				const flatIdx = row.flatIndex;
				return (
					<li
						id={`${this.uuid}-opt-add`}
						class={cls}
						role="option"
						aria-selected="false"
						onMousedown={(e: MouseEvent) => e.preventDefault()}
						onClick={(e: MouseEvent) => {
							e.preventDefault();
							e.stopPropagation();
							this.addFreeTextTag(query);
						}}
						onMouseenter={() => { this.focusedFlatIndex = flatIdx; }}
					>
						<span class="pd-dd-option-text">{this.addSuggestionText}</span>
					</li>
				);
			}
			case 'loading':
				return (
					<li class="pd-dd-loading" role="status" aria-live="polite">
						{this.resolveResource('loading', 'Loading…')}
					</li>
				);
			case 'error':
				return <li class="pd-dd-error" role="alert">{row.msg}</li>;
			case 'no-results':
				return (
					<li class="pd-dd-no-results">
						{this.resolveResource('noResultsFound', 'No results found')}
					</li>
				);
		}
	}

	/**
	 * Render only the visible window of rows. Each row is sized to the
	 * measured row height (defaults to 30px until the first measurement lands).
	 * A tall absolutely-positioned spacer holds open the scroll height.
	 */
	private renderVirtualizedList(originator: CustomRenderOriginator): VNode {
		const rows = this.buildResultDescriptors();
		const total = rows.length;
		const rowH = this.measuredRowHeight > 0 ? this.measuredRowHeight : 30;
		const viewport = this.virtualViewportHeight > 0 ? this.virtualViewportHeight : 240;
		const scrollTop = this.virtualScrollTop;
		const buffer = 5;
		const visibleStart = Math.max(0, Math.floor(scrollTop / rowH) - buffer);
		const visibleEnd = Math.min(total, Math.ceil((scrollTop + viewport) / rowH) + buffer);
		const topPad = visibleStart * rowH;
		const totalH = total * rowH;

		const visibleNodes: VNode[] = [];
		for (let i = visibleStart; i < visibleEnd; i++) {
			visibleNodes.push(this.renderRowDescriptor(rows[i], originator));
		}

		return (
			<ul
				class="pd-dd-results-list pd-dd-results-list--virtualized"
				style={`position: relative; height: ${totalH}px; padding: 0; margin: 0;`}
			>
				<div
					style={`position: absolute; top: 0; left: 0; right: 0; transform: translateY(${topPad}px);`}
				>
					{visibleNodes}
				</div>
			</ul>
		);
	}

	private renderItemContent(item: DropdownDisplayArgs, originator: CustomRenderOriginator): any {
		if (this.customRenderOption != null) {
			return this.renderCustom(
				this.customRenderOption,
				item,
				originator,
			);
		}

		return item.text;
	}

	private renderOptionItem(
		item: DropdownDisplayArgs,
		flatIndex: number,
		originator: CustomRenderOriginator,
	): VNode {
		const selected = this.isItemSelected(item);
		const focused = flatIndex === this.focusedFlatIndex;
		const cls = [
			'pd-dd-option',
			selected ? 'pd-dd-option--selected' : '',
			focused ? 'pd-dd-option--focused' : '',
		].filter(Boolean).join(' ');

		return (
			<li
				id={`${this.uuid}-opt-${flatIndex}`}
				class={cls}
				role="option"
				aria-selected={selected ? 'true' : 'false'}
				onMousedown={(e: MouseEvent) => e.preventDefault()}
				onClick={(e: MouseEvent) => this.handleItemClick(item, e)}
				onMouseenter={() => { this.focusedFlatIndex = flatIndex; }}
			>
				{this.isMultiCheckboxes && (
					/* Powerduck <CheckBox> for full visual consistency with the
					 * rest of the app. `pointer-events: none` on the wrapper
					 * (`.pd-dd-cb`) makes clicks pass through to the parent <li>
					 * which owns the toggle path — the CheckBox is purely
					 * read-only display, its `value` prop reflects the row's
					 * pending selection state. Material skin (project default,
					 * `inv-md-checkbox`) has its CSS bundled in checkbox.css —
					 * NowUi relies on Bootstrap theme CSS that isn't always
					 * loaded downstream. */
					<span class="pd-dd-cb" aria-hidden="true">
						<CheckBox
							wrap={false}
							value={selected}
							skin={CheckBoxSkin.Material}
							size={CheckBoxSize.Small}
							changed={() => { /* no-op — see comment above */ }}
						/>
					</span>
				)}
				<span class="pd-dd-option-text">{this.renderItemContent(item, originator)}</span>
				{this.renderTrailingButton(item)}
			</li>
		);
	}

	/**
	 * Optional per-option action button (e.g. "delete this tag from the event").
	 * Rendered on every option row; the caller is responsible for hiding it on
	 * rows where it does not apply (e.g. via a `:first-child` CSS rule). `clicked`
	 * receives the option's underlying dataRow.
	 */
	private renderTrailingButton(item: DropdownDisplayArgs): VNode | null {
		const btn = this.trailingButton;
		if (btn == null) { return null; }

		const fire = () => btn.clicked(item.dataRow);

		return (
			<span
				class={`pd-dd-option-trailing ${btn.cssClass ?? ''}`}
				role="button"
				tabindex={0}
				onMousedown={(e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); }}
				onClick={(e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); fire(); }}
				onKeydown={(e: KeyboardEvent) => {
					if (e.key === 'Enter' || e.key === ' ') {
						e.preventDefault();
						e.stopPropagation();
						fire();
					}
				}}
			>
				{!isNullOrEmpty(btn.icon) && <i class={btn.icon} aria-hidden="true"></i>}
				{!isNullOrEmpty(btn.text) && <span class="pd-dd-option-trailing-text">{btn.text}</span>}
			</span>
		);
	}

	// ── Public API ────────────────────────────────────────────────────────────

	public openProgrammatically(): void {
		this.open();
	}

	public closeProgrammatically(): void {
		this.close(false);
	}

	// ── Top-level render ──────────────────────────────────────────────────────

	render(_h: any): VNode {
		const cssBuilder: string[] = [];
		if (this.selected != null) {
			cssBuilder.push(`ddl-dummy-selected${String(this.selected).slice(0, 1)}`);
		}

		if (this.isInlineMode) { cssBuilder.push('dropdownlist-inline-mode'); }

		if (!isNullOrEmpty(this.cssClass)) { cssBuilder.push(this.cssClass); }

		return (
			<FormItemWrapper
				label={this.label}
				labelButtons={this.labelButtons}
				subtitle={this.subtitle}
				mandatory={this.mandatory}
				wrap={this.wrap}
				appendIcon={this.appendIcon}
				prependIcon={this.prependIcon}
				appendClicked={this.appendClicked}
				prependClicked={this.prependClicked}
				appendIconClicked={this.appendIconClicked}
				prependIconClicked={this.prependIconClicked}
				hint={this.hint}
				marginType={this.marginType}
				maxWidth={this.maxWidth}
				validationState={this.validationState}
				cssClass={cssBuilder.join(' ')}
				showClearValueButton={this.showClearValueButton}
			>
				{this.renderTrigger()}
				{this.useModalMode && (
					<Modal
						ref="pdDdModal"
						mobileMode={ModalMobileMode.BottomSheetModal}
						size={ModalSize.NormalToLarge}
						title={this.label as any}
						cssClass="pd-dd-modal"
					>
						<ModalBody>
							{this.isOpen && this.renderPanelContent('mobile')}
						</ModalBody>
					</Modal>
				)}
			</FormItemWrapper>
		);
	}
}

const DropdownList = toNative(DropdownListComponent);
export type DropdownListType = typeof DropdownList.prototype;
export default DropdownList;
