/* eslint-disable ts/no-this-alias */
/* eslint-disable no-useless-call */
import type { VNode } from 'vue';
import type { DropdownButtonItemArgs } from '../dropdown-button/dropdown-button-item';
import type { FormItemWrapperArgs, MarginType } from '../form/form-item-wrapper';
import { Prop, toNative, Watch } 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 DateUtils from '../../common/utils/date-utils';
import TemporalUtils from '../../common/utils/temporal-utils';
import FormItemWrapper from '../form/form-item-wrapper';
import Modal, { ModalMobileMode } from '../modal/modal';
import DateInputHelper from './ts/dateInputHelper';
import './css/daterange-picker.css';

// ── Types ──────────────────────────────────────────────────────────────────

export interface DaterangeChangedArgs {
	startTime: Temporal.PlainDateTime;
	endTime: Temporal.PlainDateTime;
}

export interface DaterangePickerCellRenderDay {
	date: Temporal.PlainDateTime;
	day: number;
	extraClass: string;
	time: number;
	tooltip: string;
	type: 'toMonth' | 'nextMonth' | 'lastMonth';
	valid: boolean;
}

export interface DaterangePickerCellRenderArgs {
	attributes: { [index: string]: string };
	day: DaterangePickerCellRenderDay;
}

type CalendarPlacement = 'body' | 'inline' | 'input-container-leftalign' | 'input-container-rightalign';
type MobileVariant = 'paginate' | 'verticalScroll';

interface RenderCtx {
	mode: 'single' | 'range' | 'multi';
	todayMs: number;
	startMs: number | null;
	endMs: number | null;
	hoverMs: number | null;
	minMs: number | null;
	maxMs: number | null;
	multiKeys: Set<number>;
	/** Effective maxDays cap (0 if not applicable). */
	maxDaysCap: number;
}

interface DaterangePickerArgs extends FormItemWrapperArgs {
	disabled?: boolean;
	value?: DaterangeChangedArgs;
	placeholder?: string;
	format?: string;
	enableTime?: boolean;
	autoClose?: boolean;
	commitPartialRangeOnClose?: boolean;
	/**
	 * Range mode only: the FIRST calendar click immediately commits a same-day
	 * range (`startTime` = `endTime` = clicked day) via `changed`, instead of
	 * staying silent until the second click. The picker still awaits the second
	 * click, which extends the range's end date as usual (hover preview, swap
	 * and maxDays validation all keep working) — the echo of the same-day
	 * commit arriving back through `value` does NOT complete the pending
	 * selection. Designed for always-open inline filter calendars where a
	 * single day must be a valid selection (e.g. the shop order filter drawer).
	 */
	commitFirstClickAsSameDay?: boolean;
	separator?: string;
	inputReadOnly?: boolean;
	minDate?: Temporal.PlainDateTime;
	alwaysOpen?: boolean;
	singleDate?: boolean;
	pickerRootCssClass?: string;
	maxDate?: Temporal.PlainDateTime;
	/**
	 * Month to open the calendar on when nothing is selected. Falls back to the
	 * current month when omitted, so existing callers are unaffected. Used by the
	 * shop product calendar to default to the first available (future) month.
	 */
	defaultAnchorDate?: Temporal.PlainDateTime;
	forceModalMode?: boolean;
	prevIcon?: string;
	nextIcon?: string;
	hideYearInMonthName?: boolean;
	singleMonth?: boolean;
	calendarPlacement?: CalendarPlacement;
	ensureMonthContinuity?: boolean;
	monthSelect?: boolean;
	yearSelect?: boolean;
	customInputText?: () => string;
	customTooltip?: (dayCount: number) => VNode | string;
	maxDays?: number;
	maxDaysTooltip?: (maxDays: number) => VNode | string;
	getCell?: (args: DaterangePickerCellRenderArgs) => VNode;
	changed?: (newValue: DaterangeChangedArgs) => void;
	showCustomClearButton?: boolean;
	// New features
	multiDate?: boolean;
	multiValue?: Temporal.PlainDateTime[];
	multiChanged?: (dates: Temporal.PlainDateTime[]) => void;
	/** When the count of selected dates exceeds this value, input shows "Selected: N days" instead of expanded list. */
	multiSummaryThreshold?: number;
	mobileVariant?: MobileVariant;
	confirmText?: string;
	cancelText?: string;
	/**
	 * Called when the user presses Enter inside the input. Fires after the input
	 * text is committed and the picker is closed. Useful for advancing focus to
	 * the next field in a multi-step form.
	 */
	confirmedByEnter?: () => void;
	/**
	 * Optional footer info row rendered below the calendar months (and time row
	 * if enabled). Displayed as `(i) <text>` with a top-border separator.
	 * Intended for currency disclaimers, e.g. "Ceny zobrazené v mene EUR".
	 */
	footerInfo?: VNode | string;
}

// ── Translations ───────────────────────────────────────────────────────────
//
// Only the strings the browser cannot synthesize live here. Month names and
// abbreviated weekday names are pulled from `Intl.DateTimeFormat` on first use
// per locale and cached — same approach as `datetime-picker.tsx`.

/**
 * Only the strings that have NO Intl-derived equivalent. Day, days, hour,
 * minute are pulled from Intl.DisplayNames + Intl.NumberFormat at runtime;
 * month names and weekday abbreviations come from Intl.DateTimeFormat.
 */
interface PickerLanguage {
	selected: string;
	apply: string;
	confirm: string;
	cancel: string;
	time: string;
}

const LANGUAGES: { [k: string]: PickerLanguage } = {
	default: { selected: 'Selected:', apply: 'Close', confirm: 'Confirm', cancel: 'Cancel', time: 'Time' },
	cz: { selected: 'Vybráno:', apply: 'Zavřít', confirm: 'Potvrdit', cancel: 'Zrušit', time: 'Čas' },
	de: { selected: 'Auswahl:', apply: 'Schließen', confirm: 'Bestätigen', cancel: 'Abbrechen', time: 'Zeit' },
	hu: { selected: 'Kiválasztva:', apply: 'Bezárás', confirm: 'Megerősítés', cancel: 'Mégse', time: 'Idő' },
	pl: { selected: 'Wybrany:', apply: 'Zamknij', confirm: 'Potwierdź', cancel: 'Anuluj', time: 'Czas' },
	sk: { selected: 'Vybrané:', apply: 'Zavrieť', confirm: 'Potvrdiť', cancel: 'Zrušiť', time: 'Čas' },
};

/** Map our internal language key to a tag accepted by Intl.DateTimeFormat. */
const intlTagFor = (key: string): string => {
	if (key === 'cz') {
		return 'cs';
	}

	if (key === 'default') {
		return 'en';
	}

	return key;
};

// ── Helpers ────────────────────────────────────────────────────────────────

const DATE_FORMAT_FOR_RANGE_PICKER = (() => {
	// eslint-disable-next-line no-restricted-syntax
	const dummyDate = new Date(Date.UTC(
		2022,
		11,
		20,
	));
	const formatted = dummyDate.toLocaleDateString(PowerduckState.getCurrentLanguage(), {
		day: '2-digit',
		month: '2-digit',
		year: 'numeric',
		timeZone: 'UTC',
	});
	return formatted.replace('20', 'dd').replace('12', 'MM').replace('2022', 'yyyy');
})();

const DATE_FORMAT_FOR_RANGE_PICKER_WITH_TIME = `${DATE_FORMAT_FOR_RANGE_PICKER.split('. ').join('.')} HH:mm`;

const SINGLE_MONTH_THRESHOLD_W = 700;
const SINGLE_MONTH_THRESHOLD_H = 550;
const MS_PER_DAY = 86_400_000;

/** Reused for `RenderCtx.multiKeys` whenever we are NOT in multi mode. */
const EMPTY_NUMBER_SET: Set<number> = new Set();

/**
 * Single Date reused inside cell loops to decompose ms cursor positions into
 * year/month/day without allocating a Date per cell. Safe because cell loops
 * are synchronous and never re-entered.
 */
// eslint-disable-next-line no-restricted-syntax -- mutation-target Date for ms→ymd decomposition
const SHARED_TMP_DATE = new Date(0);

// Per-instance suffix for keyboard-focus cell ids (aria-activedescendant target).
let drpKbdInstanceSeq = 0;

const compareDay = (a: Temporal.PlainDateTime, b: Temporal.PlainDateTime): number =>
	Temporal.PlainDate.compare(a.toPlainDate(), b.toPlainDate());

const compareMonth = (a: Temporal.PlainDateTime, b: Temporal.PlainDateTime): number => {
	if (a.year !== b.year) {
		return a.year < b.year ? -1 : 1;
	}

	if (a.month !== b.month) {
		return a.month < b.month ? -1 : 1;
	}

	return 0;
};

const nextMonthDate = (d: Temporal.PlainDateTime): Temporal.PlainDateTime => d.add({ months: 1 });
const prevMonthDate = (d: Temporal.PlainDateTime): Temporal.PlainDateTime => d.subtract({ months: 1 });

const sameDay = (a: Temporal.PlainDateTime | null, b: Temporal.PlainDateTime | null): boolean => {
	if (a == null || b == null) {
		return false;
	}

	return a.year === b.year && a.month === b.month && a.day === b.day;
};

/**
 * Lazily resolve and cache the active language pack the same way
 * `datetime-picker.tsx` caches its locale data: the first call after a locale
 * change does the lookup once; every subsequent translate/getMonthName call
 * just reads from the cache. Avoids the PowerduckState round-trip and the
 * `DateInputHelper.getLocale()` chain on every cell render.
 */
interface PickerLocaleData {
	lang: PickerLanguage;
	startOfWeek: 'monday' | 'sunday';
	/** 12 month names from Intl (1-indexed: monthNames[0] = January). */
	monthNames: string[];
	/** 7 weekday abbreviations already rotated to start with the configured week-start day. */
	weekdaysOrdered: string[];
	/** "Day" label from Intl.DisplayNames(dateTimeField), capitalised. */
	dayLabel: string;
	/** "Hour" label from Intl.DisplayNames(dateTimeField), capitalised. */
	hourLabel: string;
	/** "Minute" label from Intl.DisplayNames(dateTimeField), capitalised. */
	minuteLabel: string;
	/**
	 * Pre-built unit formatter for "N day(s)" — Intl.NumberFormat handles the
	 * locale's plural rules (Slovak "1 deň" / "2 dni" / "5 dní") in one call.
	 */
	dayUnitFormatter: Intl.NumberFormat;
}

const localeDataCache: Record<string, PickerLocaleData> = {};

const resolveLocaleKey = (): string => {
	let lang = DateInputHelper.getLocale();
	if (lang === 'cs') {
		lang = 'cz';
	}

	return lang in LANGUAGES ? lang : 'default';
};

const buildLocaleData = (key: string): PickerLocaleData => {
	const intlTag = intlTagFor(key);

	const monthNames: string[] = [];
	for (let i = 0; i < 12; i++) {
		// eslint-disable-next-line no-restricted-syntax -- Intl needs a Date; result is cached forever.
		const d = new Date(Date.UTC(
			2024,
			i,
			15,
		));
		monthNames.push(d.toLocaleDateString(intlTag, { month: 'long', timeZone: 'UTC' }));
	}
	// 2024-01-01 falls on a Monday; iterate 7 days to collect Mon→Sun short names.
	const weekdaysMonFirst: string[] = [];
	for (let i = 0; i < 7; i++) {
		// eslint-disable-next-line no-restricted-syntax -- same justification as above.
		const d = new Date(Date.UTC(
			2024,
			0,
			1 + i,
		));
		let short = d.toLocaleDateString(intlTag, { weekday: 'short', timeZone: 'UTC' });
		// Strip trailing dots/whitespace that some locales include (e.g. "lun.")
		// and clamp to 2 chars to match the legacy header look ("PO ÚT ST …").
		short = short.replace(/[.\s]+$/, '');
		if (short.length > 2) {
			short = short.substring(0, 2);
		}

		weekdaysMonFirst.push(short);
	}

	const startOfWeek: 'monday' | 'sunday' = DateInputHelper.getStartOfWeek() === 0 ? 'sunday' : 'monday';
	const weekdaysOrdered = startOfWeek === 'monday'
		? weekdaysMonFirst
		: [
				weekdaysMonFirst[6],
				...weekdaysMonFirst.slice(0, 6),
			];

	// dateTimeField labels — Intl.DisplayNames returns the locale name for the
	// field itself ("day"/"deň"/"Tag"/…). [capitalize] matches the legacy UI casing.
	const fieldNames = new Intl.DisplayNames([intlTag], { type: 'dateTimeField' });
	const dayLabel = (fieldNames.of('day') ?? 'day')[capitalize]();
	const hourLabel = (fieldNames.of('hour') ?? 'hour')[capitalize]();
	const minuteLabel = (fieldNames.of('minute') ?? 'minute')[capitalize]();

	// Pre-built once per locale; reuse for any N. NumberFormat handles plural
	// rules ("1 deň", "2 dni", "5 dní" for Slovak) in a single call.
	const dayUnitFormatter = new Intl.NumberFormat(intlTag, {
		style: 'unit',
		unit: 'day',
		unitDisplay: 'long',
	});

	return {
		lang: LANGUAGES[key] ?? LANGUAGES.default,
		startOfWeek,
		monthNames,
		weekdaysOrdered,
		dayLabel,
		hourLabel,
		minuteLabel,
		dayUnitFormatter,
	};
};

const getLocaleData = (): PickerLocaleData => {
	const key = resolveLocaleKey();
	const cached = localeDataCache[key];
	if (cached) {
		return cached;
	}

	const data = buildLocaleData(key);
	localeDataCache[key] = data;
	return data;
};

const translate = (key: keyof PickerLanguage): string =>
	getLocaleData().lang[key] ?? LANGUAGES.default[key];

const getMonthName = (monthIdx: number): string => getLocaleData().monthNames[monthIdx - 1];

const getStartOfWeek = (): 'monday' | 'sunday' => getLocaleData().startOfWeek;

/** Format "N day(s)" using the locale's plural rules via Intl.NumberFormat. */
const formatDayCount = (count: number): string =>
	getLocaleData().dayUnitFormatter.format(count);

const utcEpochAtMidnight = (
	year: number,
	month: number,
	day: number,
): number =>
	// eslint-disable-next-line no-restricted-syntax -- UTC epoch for grid keying
	Date.UTC(
		year,
		month - 1,
		day,
	);

/**
 * Format a same-month range using the locale's format string by replacing the
 * day token with "fromDay-toDay". Works for any format that uses lowercase `d`
 * for the day component.
 */
const formatDayRange = (
	from: Temporal.PlainDateTime,
	to: Temporal.PlainDateTime,
	fmt: string,
): string => {
	const placeholder = '☃'; // unique sentinel — will not appear in any locale string
	const stripped = fmt.replace(/d+/g, placeholder);
	const formatted = DateUtils.formatDate(from, stripped);
	return formatted.replace(placeholder, `${from.day}-${to.day}`);
};

// ── Component ──────────────────────────────────────────────────────────────

@Component
class DaterangePickerComponent extends TsxComponent<DaterangePickerArgs> implements DaterangePickerArgs {
	@Prop() label!: string | VNode;
	@Prop() labelButtons!: DropdownButtonItemArgs[];
	@Prop() cssClass!: string;
	@Prop() subtitle!: string;
	@Prop() value!: DaterangeChangedArgs;
	@Prop() placeholder!: string;
	@Prop() mandatory!: boolean;
	@Prop() customInputText?: () => string;
	@Prop() inputReadOnly?: boolean;
	@Prop() alwaysOpen!: boolean;
	@Prop() singleDate!: boolean;
	@Prop() pickerRootCssClass!: string;
	@Prop() forceModalMode?: boolean;
	@Prop() wrap!: boolean;
	@Prop() hint: string;
	@Prop() appendIcon: string;
	@Prop() prependIcon: string;
	@Prop() getCell?: (args: DaterangePickerCellRenderArgs) => VNode;
	@Prop() appendClicked: () => void;
	@Prop() prependClicked: () => void;
	@Prop() prependIconClicked: () => void;
	@Prop() appendIconClicked: () => void;
	@Prop() marginType?: MarginType;
	@Prop() calendarPlacement?: CalendarPlacement;
	@Prop() changed: (newValue: DaterangeChangedArgs) => void;
	@Prop() format?: string;
	@Prop() showClearValueButton!: boolean;
	@Prop() autoClose?: boolean;
	@Prop() commitPartialRangeOnClose?: boolean;
	@Prop() commitFirstClickAsSameDay?: boolean;
	@Prop() enableTime?: boolean;
	@Prop() separator?: string;
	@Prop() singleMonth?: boolean;
	@Prop() minDate?: Temporal.PlainDateTime;
	@Prop() maxDate?: Temporal.PlainDateTime;
	@Prop() defaultAnchorDate?: Temporal.PlainDateTime;
	@Prop() prevIcon?: string;
	@Prop() nextIcon?: string;
	@Prop() hideYearInMonthName?: boolean;
	@Prop() disabled!: boolean;
	@Prop() readOnly?: boolean;
	@Prop() monthSelect!: boolean;
	@Prop() yearSelect!: boolean;
	@Prop() customTooltip?: (dayCount: number) => VNode | string;
	@Prop() maxDays?: number;
	@Prop() maxDaysTooltip?: (maxDays: number) => VNode | string;
	@Prop() ensureMonthContinuity?: boolean;
	@Prop() multiDate?: boolean;
	@Prop() multiValue?: Temporal.PlainDateTime[];
	@Prop() multiChanged?: (dates: Temporal.PlainDateTime[]) => void;
	@Prop() multiSummaryThreshold?: number;
	@Prop() mobileVariant?: MobileVariant;
	@Prop() confirmText?: string;
	@Prop() cancelText?: string;
	@Prop() confirmedByEnter?: () => void;
	@Prop() footerInfo?: VNode | string;

	// State
	_startDate: Temporal.PlainDateTime = null;
	_endDate: Temporal.PlainDateTime = null;
	_multiDates: Temporal.PlainDateTime[] = [];
	_multiDatesEntry: Temporal.PlainDateTime[] = []; // snapshot at modal open
	_anchorMonth: Temporal.PlainDateTime = Temporal.Now.plainDateTimeISO().with({ day: 1 });
	_hovering: Temporal.PlainDateTime = null;
	_isOpen: boolean = false;
	/**
	 * MobileModeConfig.shouldDisplayInModal() pinned at open() time — read via
	 * displayInModal(). The presentation decisions derived from it (modal vs
	 * inline dropdown, verticalScroll vs paginate variant, modal-mode root css)
	 * must NOT be recomputed while the picker 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 picker while close() routes down the wrong path. Cleared on
	 * close so the next open re-evaluates the current viewport.
	 */
	_activeDisplayInModal: boolean | null = null;
	// Keyboard-navigation: epoch-ms of the day cell the keyboard "cursor" is on
	// while the calendar is open. Drives the `.kbd-focused` highlight and the
	// input's aria-activedescendant. Null = keyboard grid nav not engaged.
	_kbdFocusMs: number | null = null;
	private readonly _kbdUid: string = `drp-kbd-${(drpKbdInstanceSeq += 1)}`;
	_winWidth: number = 0;
	_winHeight: number = 0;
	_outsideHandler: ((e: MouseEvent) => void) | null = null;
	_resizeHandler: (() => void) | null = null;
	_scrollOrResizeHandler: (() => void) | null = null;
	_scrollRafId: number | null = null;
	_skipNextFocus: boolean = false;
	_activeClipper: HTMLElement | null = null;
	_dropdownAnchor: 'left' | 'right' = 'left';
	_dropdownVertical: 'down' | 'up' | 'fixed' = 'down';
	_editingText: string | null = null;
	_hoverCellAnchor: { left: number; top: number; width: number } | null = null;
	/** The currently-visible month inside the vertical-scroll modal (used by the selectors). */
	_mvsAnchor: Temporal.PlainDateTime = Temporal.Now.plainDateTimeISO().with({ day: 1 });
	/** Absolute first month rendered in the scroll list. */
	_mvsStartMonth: Temporal.PlainDateTime = Temporal.Now.plainDateTimeISO().with({ day: 1 }).subtract({ months: 1 });
	/** Absolute last month rendered in the scroll list. */
	_mvsEndMonth: Temporal.PlainDateTime = Temporal.Now.plainDateTimeISO().with({ day: 1 }).add({ months: 12 });
	/** Reentrancy guard: don't run scroll handler while we're programmatically adjusting scrollTop. */
	_mvsScrollLock: boolean = false;

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

	beforeMount() {
		this.syncFromValue();

		// Seed the always-open mobile vertical-scroll window from the same anchor
		// so the inline mobile calendar renders centered on the opening month.
		// SSR + client safe — Temporal math only, no DOM/window access. The open()
		// path recomputes these for modal/toggle usage.
		const anchor = this.getInitialAnchor();
		this._mvsAnchor = anchor;
		this._mvsStartMonth = prevMonthDate(anchor);
		this._mvsEndMonth = anchor.add({ months: 12 });
	}

	mounted() {
		if (!globalState.windowExists) {
			return;
		}

		this._winWidth = window.innerWidth;
		this._winHeight = window.innerHeight;
		this._resizeHandler = () => {
			this._winWidth = window.innerWidth;
			this._winHeight = window.innerHeight;
		};
		window.addEventListener('resize', this._resizeHandler);
		if (this.alwaysOpen) {
			this._isOpen = true;
		}

		// On a verticalScroll mvs picker, scroll the month list to the selection's
		// start month (e.g. the user closed and re-opened the bottom sheet) or —
		// when nothing is selected — to the opt-in default anchor, so the user lands
		// on the first available month rather than the current month.
		const scrollTarget = this.value?.startTime ?? this.defaultAnchorDate;
		if (this.mobileVariant === 'verticalScroll' && this.displayInModal() && scrollTarget) {
			this.$nextTick(() => {
				this.scrollToMonth(scrollTarget.year, scrollTarget.month);
			});
		}
	}

	@Watch('value', { deep: true })
	private onValuePropChanged() {
		this.syncFromValue();
	}

	@Watch('multiValue', { deep: true })
	private onMultiValuePropChanged() {
		this.syncFromValue();
	}

	@Watch('defaultAnchorDate')
	private onDefaultAnchorDateChanged() {
		// An anchor that arrives after mount (async price load on a picker that
		// is not key-remounted) still re-anchors while no value is selected.
		this.syncFromValue();
	}

	beforeUnmount() {
		this.detachOutsideHandler();
		if (this._resizeHandler) {
			window.removeEventListener('resize', this._resizeHandler);
			this._resizeHandler = null;
		}
	}

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

	isOpened(): boolean {
		return this._isOpen;
	}

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

		if (this._isOpen) {
			return;
		}

		this._activeDisplayInModal = MobileModeConfig.shouldDisplayInModal();
		this._isOpen = true;
		// Snapshot the current selection BEFORE any user clicks so Cancel can
		// revert. Required for desktop AND modal — used to only run on modal.
		this.snapshotMulti();
		const reference = (this._startDate ?? Temporal.Now.plainDateTimeISO()).with({ day: 1 });
		this._mvsAnchor = reference;
		this._mvsStartMonth = prevMonthDate(reference);
		this._mvsEndMonth = reference.add({ months: 12 });
		if (this.useModalMode()) {
			this.$nextTick(() => {
				(this.$refs.drpnModal as any)?.show({
					onHidden: () => {
						this._isOpen = false;
						this._activeDisplayInModal = null;
					},
				});
				this.$nextTick(() => {
					this.scrollMvsToCurrent();
				});
			});
			return;
		}

		this.$nextTick(() => {
			this.attachOutsideHandler();
			this.adjustDropdownAnchor();
		});
	}

	/**
	 * Opens the picker AND focuses the inner input — useful when programmatically
	 * advancing focus from another field (e.g. an Enter-key chain). Avoids the
	 * caller having to reach into the picker's DOM with querySelector.
	 */
	openDropdown(): void {
		if (this._isOpen) {
			// Already open — do not re-focus the inner input. A re-focus call here
			// steals focus from any element the user is currently interacting with
			// (notably the inline month/year <select>, whose native option list
			// closes the moment the <select> loses focus). See GOBO-187.
			return;
		}

		this.open();
		this.$nextTick(() => {
			(this.$refs.innerInput as HTMLInputElement | undefined)?.focus();
		});
	}

	private scrollMvsToCurrent() {
		const scroll = this.$refs.mvsScroll as HTMLElement | undefined;
		if (!scroll) {
			return;
		}

		const tgt = scroll.querySelector(`[data-anchor="${this._mvsAnchor.year}-${this._mvsAnchor.month}"]`) as HTMLElement | null;
		if (tgt) {
			scroll.scrollTop = tgt.offsetTop - 8;
		}
	}

	private adjustDropdownAnchor() {
		if (!globalState.windowExists) {
			return;
		}

		if (this.calendarPlacement === 'inline' || this.calendarPlacement === 'input-container-leftalign') {
			this._dropdownAnchor = 'left';
			this._dropdownVertical = 'down';

			return;
		}

		if (this.calendarPlacement === 'input-container-rightalign') {
			this._dropdownAnchor = 'right';
			this._dropdownVertical = 'down';

			return;
		}

		const wrap = (this.$el as HTMLElement)?.querySelector('.drpn-wrap') as HTMLElement | null;
		const dropdown = (this.$el as HTMLElement)?.querySelector('.drpn-dropdown') as HTMLElement | null;
		if (!wrap || !dropdown) {
			return;
		}

		// Measure once, share across both axes — avoids double DOM walks
		// (clipper detection) and redundant getBoundingClientRect() calls.
		const SAFETY_MARGIN_PX = 8;
		const wrapRect = wrap.getBoundingClientRect();
		const dropdownWidth = dropdown.offsetWidth || 600;
		const dropdownHeight = dropdown.offsetHeight || 326;
		const clipper = this.findClippingAncestor(wrap);

		// ── Horizontal axis ────────────────────────────────────────────────
		// When a clipper is found, measure inside it (the clipper may be
		// narrower than the viewport while the picker still sits inside the
		// viewport). Without this, the original viewport-only check failed
		// to flip when e.g. the inner .card.with-card-shadow was too narrow
		// for the calendar but the viewport itself was wide enough.
		if (clipper) {
			const clipperRect = clipper.getBoundingClientRect();
			const spaceRight = clipperRect.right - wrapRect.left; // anchor 'left', extends right
			const spaceLeft = wrapRect.right - clipperRect.left; // anchor 'right', extends left

			if (dropdownWidth + SAFETY_MARGIN_PX <= spaceRight) {
				this._dropdownAnchor = 'left';
			} else if (dropdownWidth + SAFETY_MARGIN_PX <= spaceLeft) {
				this._dropdownAnchor = 'right';
			} else {
				// Won't fit either way inside the clipper — pick the side with more room.
				// If the vertical axis falls through to position:fixed, the fixed-mode
				// math uses _dropdownAnchor to compute `left` and clamps to viewport.
				this._dropdownAnchor = spaceRight >= spaceLeft ? 'left' : 'right';
			}
		} else {
			// Page-level scroll context — viewport is the discriminator (status quo).
			const overflowsRight = wrapRect.left + dropdownWidth > window.innerWidth - SAFETY_MARGIN_PX;
			this._dropdownAnchor = overflowsRight ? 'right' : 'left';
		}

		// Measure available space within the intersection of the clipping
		// ancestor and the viewport — a clipper that extends past the viewport
		// bottom must not be trusted as available space.
		const clipperRect = clipper ? clipper.getBoundingClientRect() : null;
		const effectiveBottom = clipperRect
			? Math.min(clipperRect.bottom, window.innerHeight)
			: window.innerHeight;
		const effectiveTop = clipperRect
			? Math.max(clipperRect.top, 0)
			: 0;
		const spaceBelow = effectiveBottom - wrapRect.bottom;
		const spaceAbove = wrapRect.top - effectiveTop;
		const requiredHeight = dropdownHeight + SAFETY_MARGIN_PX;
		const fitsBelow = requiredHeight <= spaceBelow;
		const fitsAbove = requiredHeight <= spaceAbove;

		// When both fit, prefer the side with more breathing room so the
		// picker isn't pinned against the viewport edge.
		if (fitsAbove && (!fitsBelow || spaceAbove > spaceBelow)) {
			this._dropdownVertical = 'up';
			this.clearFixedInlineStyles(dropdown);

			return;
		}

		if (fitsBelow) {
			this._dropdownVertical = 'down';
			this.clearFixedInlineStyles(dropdown);

			return;
		}

		if (!clipper) {
			this._dropdownVertical = spaceBelow >= spaceAbove ? 'down' : 'up';
			this.clearFixedInlineStyles(dropdown);

			return;
		}

		// position:fixed resolves against the nearest transformed ancestor in
		// some browsers — fall back to 'up' rather than mis-pinning.
		if (this.hasTransformedAncestor(wrap)) {
			this._dropdownVertical = 'up';
			this.clearFixedInlineStyles(dropdown);

			return;
		}

		// Position: fixed — pin to viewport. Clamp `left` so the dropdown can't
		// extend past viewport edges (the existing math could push the right edge
		// off-screen if the picker sat far right).
		this._dropdownVertical = 'fixed';
		const rawLeft = this._dropdownAnchor === 'right'
			? wrapRect.right - dropdownWidth
			: wrapRect.left;
		const minLeft = SAFETY_MARGIN_PX;
		const maxLeft = window.innerWidth - dropdownWidth - SAFETY_MARGIN_PX;
		const left = Math.min(Math.max(rawLeft, minLeft), maxLeft);
		const maxAllowedTop = window.innerHeight - dropdownHeight - SAFETY_MARGIN_PX;
		const rawTop = wrapRect.bottom + 4;
		const top = Math.min(Math.max(rawTop, SAFETY_MARGIN_PX), maxAllowedTop);
		dropdown.style.left = `${left}px`;
		dropdown.style.top = `${top}px`;
		dropdown.style.right = 'auto';
		dropdown.style.bottom = 'auto';
	}

	private findClippingAncestor(start: HTMLElement): HTMLElement | null {
		let el = start.parentElement;
		while (el && el !== document.body && el !== document.documentElement) {
			const cs = getComputedStyle(el);
			if (cs.overflowY === 'hidden' || cs.overflowY === 'auto' || cs.overflowY === 'scroll' || cs.overflowY === 'clip') {
				return el;
			}

			el = el.parentElement;
		}

		return null;
	}

	private hasTransformedAncestor(start: HTMLElement): boolean {
		let el = start.parentElement;
		while (el && el !== document.body && el !== document.documentElement) {
			const cs = getComputedStyle(el);
			if (cs.transform !== 'none' || cs.perspective !== 'none' || cs.filter !== 'none') {
				return true;
			}

			el = el.parentElement;
		}

		return false;
	}

	private clearFixedInlineStyles(dropdown: HTMLElement) {
		dropdown.style.left = '';
		dropdown.style.top = '';
		dropdown.style.right = '';
		dropdown.style.bottom = '';
	}

	close(): void {
		if (this.alwaysOpen) {
			return;
		}

		// Clear keyboard grid focus so the next open starts fresh.
		this._kbdFocusMs = null;

		// Reset in-progress range: closing without picking the second date
		// (outside-click, Escape, programmatic) clears any partial selection
		// AND notifies the parent that no range is selected. Without this,
		// the abandoned _startDate would leak into the next open.
		if (this.getMode() === 'range' && this._startDate != null && this._endDate == null) {
			// commitFirstClickAsSameDay already committed the lone date as a same-day
			// range on click — closing must finalize that selection, not discard it.
			if (this.commitPartialRangeOnClose || this.commitFirstClickAsSameDay) {
				// Opt-in: commit the lone picked date as a same-day range instead
				// of discarding it, so the parent receives a "pick one day" signal
				// on close. The committed value round-trips back in via `value`, so
				// no stale _startDate leaks into the next open.
				this._endDate = this._startDate;
			} else {
				this._startDate = null;
				this._endDate = null;
			}

			this.raiseChanged();
		}

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

		this._isOpen = false;
		this._activeDisplayInModal = null;
		this.detachOutsideHandler();
	}

	toggle(): void {
		if (this._isOpen) {
			this.close();
		} else {
			this.open();
		}
	}

	openInModal(): void {
		this.open();
	}

	clearValue(): void {
		this._startDate = null;
		this._endDate = null;
		this._multiDates = [];
		this.raiseChanged();
	}

	// ── Open / close helpers ──────────────────────────────────────────────

	private attachOutsideHandler() {
		this.detachOutsideHandler();
		const self = this;
		this._outsideHandler = (e: MouseEvent) => {
			const target = e.target as Node;
			if (self.$el && !self.$el.contains(target)) {
				self.close.call(self);
			}
		};
		this._scrollOrResizeHandler = () => {
			// Debounce via rAF — scroll fires per pixel; we only need one
			// re-measure per frame, and we want it timed to the next paint.
			if (self._scrollRafId !== null) {
				return;
			}

			self._scrollRafId = window.requestAnimationFrame(() => {
				self._scrollRafId = null;
				if (self._isOpen) {
					self.adjustDropdownAnchor.call(self);
				}
			});
		};

		document.addEventListener('mousedown', this._outsideHandler);
		window.addEventListener('resize', this._scrollOrResizeHandler);

		const wrap = (this.$el as HTMLElement)?.querySelector('.drpn-wrap') as HTMLElement | null;
		if (wrap) {
			this._activeClipper = this.findClippingAncestor(wrap);
			if (this._activeClipper) {
				this._activeClipper.addEventListener(
					'scroll',
					this._scrollOrResizeHandler,
					{ passive: true },
				);
			}
		}
	}

	private detachOutsideHandler() {
		if (this._outsideHandler) {
			document.removeEventListener('mousedown', this._outsideHandler);
			this._outsideHandler = null;
		}

		if (this._scrollOrResizeHandler) {
			window.removeEventListener('resize', this._scrollOrResizeHandler);
			if (this._activeClipper) {
				this._activeClipper.removeEventListener('scroll', this._scrollOrResizeHandler);
				this._activeClipper = null;
			}

			this._scrollOrResizeHandler = null;
		}

		if (this._scrollRafId !== null) {
			window.cancelAnimationFrame(this._scrollRafId);
			this._scrollRafId = null;
		}
	}

	// ── Mode resolution ───────────────────────────────────────────────────

	/**
	 * Pinned-aware read of MobileModeConfig.shouldDisplayInModal(): while the
	 * picker is open, returns the value captured at open() (see
	 * `_activeDisplayInModal`); while closed, evaluates live.
	 */
	private displayInModal(): boolean {
		return this._activeDisplayInModal ?? MobileModeConfig.shouldDisplayInModal();
	}

	useModalMode(): boolean {
		if (this.forceModalMode) {
			return true;
		}

		if (this.alwaysOpen && this.calendarPlacement != null && this.calendarPlacement !== 'body') {
			return false;
		}

		if (this.displayInModal()) {
			return true;
		}

		return false;
	}

	getMode(): 'single' | 'range' | 'multi' {
		if (this.multiDate === true) {
			return 'multi';
		}

		if (this.singleDate === true) {
			return 'single';
		}

		return 'range';
	}

	getEffectiveSingleMonth(): boolean {
		if (this.singleMonth === true) {
			return true;
		}

		if (!globalState.windowExists) {
			return false;
		}

		if (this._winWidth < 480) {
			return true;
		}

		if (
			this._winWidth < SINGLE_MONTH_THRESHOLD_W
			&& this._winHeight < SINGLE_MONTH_THRESHOLD_H
			&& !this.enableTime
		) {
			return true;
		}

		return false;
	}

	getMobileVariant(): MobileVariant {
		if (this.mobileVariant != null) {
			return this.mobileVariant;
		}

		return this.displayInModal() ? 'verticalScroll' : 'paginate';
	}

	getFormat(): string {
		if (this.format) {
			return this.format;
		}

		return this.enableTime ? DATE_FORMAT_FOR_RANGE_PICKER_WITH_TIME : DATE_FORMAT_FOR_RANGE_PICKER;
	}

	getSeparator(): string {
		return this.separator ?? ' ~ ';
	}

	// ── Value sync ────────────────────────────────────────────────────────

	/**
	 * The month the calendar should open on when nothing is selected. Priority:
	 * an active selection (single/range start, or the first multi date), then the
	 * opt-in `defaultAnchorDate`, then today — so callers that pass neither prop
	 * keep the current-month behaviour exactly.
	 */
	private getInitialAnchor(): Temporal.PlainDateTime {
		const base = this.value?.startTime
			?? (this.getMode() === 'multi' ? this.multiValue?.[0] : null)
			?? this.defaultAnchorDate
			?? Temporal.Now.plainDateTimeISO();
		return base.with({ day: 1 });
	}

	private syncFromValue() {
		// Single source of truth for the opening month — covers the multi,
		// value-null and value-set paths uniformly. Assigned before the multi
		// early-return so an unchanged multiValue still re-anchors once a
		// `defaultAnchorDate` arrives.
		this._anchorMonth = this.getInitialAnchor();

		if (this.getMode() === 'multi') {
			const incoming = this.multiValue ?? [];
			if (this.areMultiEqual(incoming, this._multiDates)) {
				return;
			}

			this._multiDates = incoming.slice();
			return;
		}

		const v = this.value;
		if (v == null || v.startTime == null) {
			this._startDate = null;
			this._endDate = null;
			return;
		}

		// commitFirstClickAsSameDay: the first-click commit immediately echoes
		// back through `value` as a same-day range. Completing the pending
		// selection from that echo would turn the second click into the start of
		// a NEW range, so while the second click is still awaited, the exact echo
		// of the pending start keeps the in-progress selection alive. Any other
		// incoming value (clear button, external reset, a different day) syncs
		// normally and resets the pending state.
		if (this.commitFirstClickAsSameDay
			&& this._startDate != null && this._endDate == null
			&& sameDay(v.startTime, this._startDate)
			&& sameDay(v.endTime ?? v.startTime, this._startDate)) {
			return;
		}

		this._startDate = v.startTime;
		this._endDate = v.endTime ?? v.startTime;
	}

	private areMultiEqual(a: Temporal.PlainDateTime[], b: Temporal.PlainDateTime[]): boolean {
		if (a === b) {
			return true;
		}

		if (!a || !b) {
			return false;
		}

		if (a.length !== b.length) {
			return false;
		}

		for (let i = 0; i < a.length; i++) {
			if (!sameDay(a[i], b[i])) {
				return false;
			}
		}
		return true;
	}

	private snapshotMulti() {
		this._multiDatesEntry = (this._multiDates ?? []).slice();
	}

	private raiseChanged() {
		if (this.getMode() === 'multi') {
			this.multiChanged?.(this._multiDates.slice());
			return;
		}

		if (this.changed == null) {
			return;
		}

		if (this._startDate == null) {
			this.changed(null);
			return;
		}

		this.changed({
			startTime: this._startDate,
			endTime: this._endDate ?? this._startDate,
		});
	}

	// ── Selection logic ───────────────────────────────────────────────────

	private isInvalidByBounds(date: Temporal.PlainDateTime): boolean {
		if (this.minDate && compareDay(date, this.minDate) < 0) {
			return true;
		}

		if (this.maxDate && compareDay(date, this.maxDate) > 0) {
			return true;
		}

		return false;
	}

	private isInvalidByMaxDays(date: Temporal.PlainDateTime): boolean {
		if (this.getMode() !== 'range') {
			return false;
		}

		if (!this.maxDays || this.maxDays <= 0) {
			return false;
		}

		if (this._startDate == null || this._endDate != null) {
			return false;
		}

		const days = Math.abs(this.daysBetween(date, this._startDate)) + 1;
		return days > this.maxDays;
	}

	private daysBetween(a: Temporal.PlainDateTime, b: Temporal.PlainDateTime): number {
		const ams = utcEpochAtMidnight(
			a.year,
			a.month,
			a.day,
		);
		const bms = utcEpochAtMidnight(
			b.year,
			b.month,
			b.day,
		);
		return Math.round((ams - bms) / MS_PER_DAY);
	}

	private isCellSelectable(date: Temporal.PlainDateTime): boolean {
		if (this.isInvalidByBounds(date)) {
			return false;
		}

		if (this.isInvalidByMaxDays(date)) {
			return false;
		}

		return true;
	}

	dayClicked(date: Temporal.PlainDateTime) {
		if (!this.isCellSelectable(date)) {
			return;
		}

		const mode = this.getMode();
		if (mode === 'single') {
			this._startDate = date;
			this._endDate = date;
			this.raiseChanged();
			if (this.autoClose !== false) {
				this.close();
			}

			return;
		}

		if (mode === 'multi') {
			const idx = this._multiDates.findIndex(d => sameDay(d, date));
			if (idx >= 0) {
				this._multiDates.splice(idx, 1);
			} else {
				if (this.maxDays && this._multiDates.length >= this.maxDays) {
					return;
				}

				this._multiDates.push(date);
				this._multiDates.sort((a, b) => compareDay(a, b));
			}

			return; // Confirm/Cancel commits the change in multi mode
		}

		// Range mode
		if ((this._startDate && this._endDate) || (!this._startDate && !this._endDate)) {
			this._startDate = date;
			this._endDate = null;
			this._hovering = null;

			// Opt-in: the lone first click is already a valid selection — commit it
			// as a same-day range right away (raiseChanged falls back to _startDate
			// for the missing end). The pending `_endDate == null` state survives, so
			// the next click still extends this range instead of starting a new one;
			// syncFromValue swallows the echo of this commit for the same reason.
			if (this.commitFirstClickAsSameDay) {
				this.raiseChanged();
			}
		} else if (this._startDate) {
			let s = this._startDate;
			let e = date;
			if (compareDay(s, e) > 0) {
				const tmp = s;
				s = e;
				e = tmp;
			}

			this._startDate = s;
			this._endDate = e;
			this.raiseChanged();
			if (this.autoClose !== false) {
				this.close();
			}
		}
	}

	dayHovered(date: Temporal.PlainDateTime, cellEl?: HTMLElement) {
		if (this.getMode() !== 'range') {
			return;
		}

		if (!this.isCellSelectable(date)) {
			// Disabled cells (out of bounds OR past maxDays cap) MUST NOT participate
			// in the hover preview. Without this guard, the cell receives the
			// `hovering` / `hoveringCurrentFirst` / `hoveringCurrentBefore` classes
			// and shop-side SCSS paints a dark filled circle on a date the user
			// cannot pick. See GOBO-187 (bug 2).
			this._hovering = null;
			this._hoverCellAnchor = null;
			return;
		}

		if (this._startDate && !this._endDate) {
			this._hovering = date;
			if (cellEl) {
				const root = (this.$el as HTMLElement)?.querySelector('.drpn-content') as HTMLElement | null;
				if (root) {
					const cellRect = cellEl.getBoundingClientRect();
					const rootRect = root.getBoundingClientRect();
					this._hoverCellAnchor = {
						left: cellRect.left - rootRect.left + cellRect.width / 2,
						top: cellRect.top - rootRect.top,
						width: cellRect.width,
					};
				}
			}
		}
	}

	clearHover() {
		this._hovering = null;
		this._hoverCellAnchor = null;
	}

	// ── Cell state classification ─────────────────────────────────────────

	/**
	 * Build a per-render context with all values pre-computed once so each
	 * day-cell classification is just integer comparisons + Set lookups.
	 * Cell counts run 84+ per render; we don't want Temporal allocations or
	 * O(N) array scans inside that loop.
	 */
	private buildRenderCtx(): RenderCtx {
		const todayPlain = Temporal.Now.plainDateTimeISO();
		const todayMs = utcEpochAtMidnight(
			todayPlain.year,
			todayPlain.month,
			todayPlain.day,
		);

		const startMs = this._startDate
			? utcEpochAtMidnight(
					this._startDate.year,
					this._startDate.month,
					this._startDate.day,
				)
			: null;
		const endMs = this._endDate
			? utcEpochAtMidnight(
					this._endDate.year,
					this._endDate.month,
					this._endDate.day,
				)
			: null;
		const hoverMs = this._hovering
			? utcEpochAtMidnight(
					this._hovering.year,
					this._hovering.month,
					this._hovering.day,
				)
			: null;
		const minMs = this.minDate
			? utcEpochAtMidnight(
					this.minDate.year,
					this.minDate.month,
					this.minDate.day,
				)
			: null;
		const maxMs = this.maxDate
			? utcEpochAtMidnight(
					this.maxDate.year,
					this.maxDate.month,
					this.maxDate.day,
				)
			: null;

		const mode = this.getMode();
		// Only iterate `_multiDates` when actually in multi mode. Range/single
		// modes never query the Set; sharing a single empty sentinel saves a
		// Set allocation per render.
		let multiKeys: Set<number> = EMPTY_NUMBER_SET;
		if (mode === 'multi' && this._multiDates.length > 0) {
			multiKeys = new Set<number>();
			for (const d of this._multiDates) {
				multiKeys.add(utcEpochAtMidnight(
					d.year,
					d.month,
					d.day,
				));
			}
		}

		// maxDays validity in range mode: only when start picked but end is not
		const maxDaysCap = (mode === 'range' && this.maxDays && this.maxDays > 0 && startMs != null && endMs == null)
			? this.maxDays
			: 0;

		return {
			mode,
			todayMs,
			startMs,
			endMs,
			hoverMs,
			minMs,
			maxMs,
			multiKeys,
			maxDaysCap,
		};
	}

	private classifyDay(
		date: Temporal.PlainDateTime,
		anchorMonth: Temporal.PlainDateTime,
		ctx: RenderCtx,
		cellMs: number,
	) {
		// Cheap integer comparisons; no Temporal allocations.
		const sameMonth = date.year === anchorMonth.year && date.month === anchorMonth.month;
		const type: 'toMonth' | 'lastMonth' | 'nextMonth' = sameMonth
			? 'toMonth'
			: (date.year < anchorMonth.year || (date.year === anchorMonth.year && date.month < anchorMonth.month))
					? 'lastMonth'
					: 'nextMonth';
		const isToday = cellMs === ctx.todayMs;

		const isMulti = ctx.mode === 'multi';
		const isInMulti = isMulti && ctx.multiKeys.has(cellMs);
		const isStart = !isMulti && ctx.startMs != null && cellMs === ctx.startMs;
		const isEnd = !isMulti && ctx.endMs != null && cellMs === ctx.endMs;
		const inRange = !isMulti && ctx.startMs != null && ctx.endMs != null
			&& cellMs >= ctx.startMs && cellMs <= ctx.endMs;

		// Hover preview (range only, partial selection).
		//
		// When `maxDaysCap` is active (range mode + maxDays > 0 + start picked +
		// end not picked) we clip the *preview range* at the inclusive cap
		// (`startMs ± (maxDaysCap-1) days`). Without the clip the hover
		// highlight extends across cells that are visibly `.invalid` (greyed
		// out by the same cap), giving the user the false impression they are
		// about to commit a range past the cap. Past-cap cells therefore still
		// get only the static `.invalid` look — no preview overlay on top.
		let hovering = false;
		let hoveringCurrentFirst = false;
		let hoveringCurrentLast = false;
		let hoveringCurrentBefore = false;
		if (ctx.mode === 'range' && ctx.hoverMs != null) {
			const startSelected = ctx.startMs != null && ctx.endMs == null;
			const noneSelected = ctx.startMs == null && ctx.endMs == null;
			const bothSelected = ctx.startMs != null && ctx.endMs != null;
			// Compute the effective preview destination, clipped at the cap.
			let effectiveHoverMs = ctx.hoverMs;
			if (startSelected && ctx.maxDaysCap > 0) {
				const capRangeMs = (ctx.maxDaysCap - 1) * MS_PER_DAY;
				const capForward = ctx.startMs! + capRangeMs;
				const capBackward = ctx.startMs! - capRangeMs;
				if (effectiveHoverMs > capForward) {
					effectiveHoverMs = capForward;
				} else if (effectiveHoverMs < capBackward) {
					effectiveHoverMs = capBackward;
				}
			}

			// "Current" (the cell under cursor) — only mark cells the user can
			// actually commit. If the cursor is past the cap, the would-be
			// commit endpoint is the cap day, not the cursor cell, so we
			// suppress the hovering-current marker on the past-cap cell.
			const cursorWithinCap = effectiveHoverMs === ctx.hoverMs;
			if (cellMs === ctx.hoverMs && cursorWithinCap) {
				if (noneSelected || bothSelected) {
					hoveringCurrentFirst = true;
				} else if (ctx.hoverMs < ctx.startMs!) {
					hoveringCurrentBefore = true;
				} else {
					hoveringCurrentLast = true;
				}
			}

			if (startSelected) {
				const lo = Math.min(effectiveHoverMs, ctx.startMs!);
				const hi = Math.max(effectiveHoverMs, ctx.startMs!);
				if (cellMs >= lo && cellMs <= hi) {
					hovering = true;
				}
			}
		}

		// Validity: bounds + maxDays cap
		let valid = true;
		if (ctx.minMs != null && cellMs < ctx.minMs) {
			valid = false;
		} else if (ctx.maxMs != null && cellMs > ctx.maxMs) {
			valid = false;
		} else if (ctx.maxDaysCap > 0 && ctx.startMs != null) {
			const days = Math.abs(Math.round((cellMs - ctx.startMs) / MS_PER_DAY)) + 1;
			if (days > ctx.maxDaysCap) {
				valid = false;
			}
		}

		// Cap indicator: the inclusive last-day the user can pick under the
		// active maxDays cap. Marked with `.max-day-cap` so CSS can render a
		// subtle "fence-post" boundary (dashed right border) telling the user
		// "this is the furthest you can go".
		let isMaxDayCap = false;
		if (ctx.maxDaysCap > 0 && ctx.startMs != null) {
			const capForward = ctx.startMs + (ctx.maxDaysCap - 1) * MS_PER_DAY;
			if (cellMs === capForward) {
				isMaxDayCap = true;
			}
		}

		return {
			type,
			isToday,
			isInMulti,
			isStart,
			isEnd,
			inRange,
			hovering,
			hoveringCurrentFirst,
			hoveringCurrentLast,
			hoveringCurrentBefore,
			isMaxDayCap,
			valid,
		};
	}

	// ── Render: input ─────────────────────────────────────────────────────

	/**
	 * Format and cache the start date once per render-cycle. The cache key is
	 * the format string + the date's epoch ms — both stable within a render,
	 * so back-to-back calls (input + topbar) reuse one formatDate result.
	 */
	private _startFmtCache: { ms: number; fmt: string; out: string } | null = null;
	private getStartFormatted(): string {
		if (this._startDate == null) {
			return '';
		}

		const ms = utcEpochAtMidnight(
			this._startDate.year,
			this._startDate.month,
			this._startDate.day,
		) + (this.enableTime ? this._startDate.hour * 3600000 + this._startDate.minute * 60000 : 0);
		const fmt = this.getFormat();
		const cached = this._startFmtCache;
		if (cached && cached.ms === ms && cached.fmt === fmt) {
			return cached.out;
		}

		const out = DateUtils.formatDate(this._startDate, fmt);
		this._startFmtCache = { ms, fmt, out };
		return out;
	}

	private _endFmtCache: { ms: number; fmt: string; out: string } | null = null;
	private getEndFormatted(): string {
		if (this._endDate == null) {
			return '';
		}

		const ms = utcEpochAtMidnight(
			this._endDate.year,
			this._endDate.month,
			this._endDate.day,
		) + (this.enableTime ? this._endDate.hour * 3600000 + this._endDate.minute * 60000 : 0);
		const fmt = this.getFormat();
		const cached = this._endFmtCache;
		if (cached && cached.ms === ms && cached.fmt === fmt) {
			return cached.out;
		}

		const out = DateUtils.formatDate(this._endDate, fmt);
		this._endFmtCache = { ms, fmt, out };
		return out;
	}

	/** Memoizes formatMultiDates output keyed by date list signature + format. */
	private _multiFmtCache: { sig: string; fmt: string; out: string } | null = null;
	private getMultiFormatted(): string {
		const fmt = this.getFormat();
		// Build a cheap signature from epoch days; scales O(N) but only on cache miss.
		const sig = this._multiDates
			.map(d => utcEpochAtMidnight(
				d.year,
				d.month,
				d.day,
			))
			.sort((a, b) => a - b)
			.join(',');
		const cached = this._multiFmtCache;
		if (cached && cached.sig === sig && cached.fmt === fmt) {
			return cached.out;
		}

		const out = this.formatMultiDates(this._multiDates, fmt);
		this._multiFmtCache = { sig, fmt, out };
		return out;
	}

	getDisplayText(): string {
		if (this.customInputText != null) {
			return this.customInputText() ?? '';
		}

		const mode = this.getMode();
		if (mode === 'multi') {
			const count = this._multiDates.length;
			if (count === 0) {
				return '';
			}

			if (this.multiSummaryThreshold != null && count > this.multiSummaryThreshold) {
				return `${translate('selected')} ${formatDayCount(count)}`;
			}

			return this.getMultiFormatted();
		}

		if (this._startDate == null) {
			return '';
		}

		const startStr = this.getStartFormatted();
		if (mode === 'single' || this.singleDate) {
			return startStr;
		}

		// Range mode: don't leak in-progress selection. Between the first and
		// second click _startDate is set but _endDate is null — returning
		// startStr here would make the input look "selected" with a single
		// date. Hydration via syncFromValue() always fills _endDate (defaults
		// to _startDate), so this branch only fires during in-progress picks.
		if (this._endDate == null) {
			return '';
		}

		return startStr + this.getSeparator() + this.getEndFormatted();
	}

	private formatMultiDates(dates: Temporal.PlainDateTime[], fmt: string): string {
		// Annotate with epoch ms once; sort + grouping become integer ops.
		const annotated = dates.map(d => ({
			date: d,
			ms: utcEpochAtMidnight(
				d.year,
				d.month,
				d.day,
			),
		})).sort((a, b) => a.ms - b.ms);

		const groups: Array<{ from: Temporal.PlainDateTime; to: Temporal.PlainDateTime; toMs: number }> = [];
		for (const item of annotated) {
			const last = groups[groups.length - 1];
			if (last && (item.ms - last.toMs) === MS_PER_DAY) {
				last.to = item.date;
				last.toMs = item.ms;
			} else {
				groups.push({ from: item.date, to: item.date, toMs: item.ms });
			}
		}

		return groups.map((g) => {
			if (g.from === g.to || (g.from.year === g.to.year && g.from.month === g.to.month && g.from.day === g.to.day)) {
				return DateUtils.formatDate(g.from, fmt);
			}

			if (g.from.year === g.to.year && g.from.month === g.to.month) {
				return formatDayRange(
					g.from,
					g.to,
					fmt,
				);
			}

			return `${DateUtils.formatDate(g.from, fmt)} - ${DateUtils.formatDate(g.to, fmt)}`;
		}).join(', ');
	}

	private renderInput(): VNode {
		const displayText = this.getDisplayText();
		const value = this._editingText != null ? this._editingText : displayText;
		const handleOpen = () => {
			// Reset the focus-skip flag so a stale value (set by a prior mousedown
			// that didn't lead to a click) can't suppress the next legitimate
			// Tab/keyboard focus.
			this._skipNextFocus = false;
			if (!this.disabled) {
				this.toggle();
			}
		};
		const isModal = this.useModalMode();
		const isReadOnly = this.readOnly === true || this.inputReadOnly === true || isModal;
		return (
			<div
				class="drpn-input-wrap"
				onMousedown={() => { this._skipNextFocus = true; }}
				onClick={handleOpen}
			>
				<input
					ref="innerInput"
					type="text"
					class={PowerduckState.getFormControlCssClass()}
					placeholder={this.placeholder}
					value={value}
					readonly={isReadOnly ? 'readonly' : null}
					disabled={this.disabled ? 'disabled' : null}
					onInput={(e: any) => this.onInputType(e.target.value)}
					onFocus={() => {
						// Click-induced focus: the wrapper's onClick handles toggle.
						// We must NOT also open here, or the click bubbling to onClick
						// would then toggle-close (visible as a flash of the dropdown).
						const skip = this._skipNextFocus;
						this._skipNextFocus = false;
						if (skip) {
							return;
						}

						// Tab/keyboard-induced focus: open as before.
						if (!this.disabled && !this._isOpen) {
							this.open();
						}
					}}
					onBlur={() => this.commitInputText()}
					aria-expanded={this._isOpen ? 'true' : 'false'}
					aria-activedescendant={this._isOpen && this._kbdFocusMs != null ? `${this._kbdUid}-${this._kbdFocusMs}` : undefined}
					onKeydown={(e: KeyboardEvent) => {
						// Arrow/Enter calendar navigation takes priority while open.
						if (this.handleCalendarKeydown(e)) {
							return;
						}

						if (e.key === 'Enter') {
							e.preventDefault();
							this.commitInputText();
							this.close();
							this.confirmedByEnter?.();
						} else if (e.key === 'Escape') {
							e.preventDefault();
							this._editingText = null;
							this.close();
						} else if (e.key === 'Tab' && !e.shiftKey) {
							// Forward-Tab matches Enter's "confirm and advance" semantic.
							// preventDefault so the browser's default focus-shift doesn't
							// race with the consumer's confirmedByEnter handler (which
							// typically opens the next picker and focuses its inner input).
							// Shift+Tab is left to the browser for natural reverse navigation.
							e.preventDefault();
							this.commitInputText();
							this.close();
							this.confirmedByEnter?.();
						}
					}}
				/>
				{this.showClearValueButton && this._startDate && (
					<span
						class="pd-daterange-picker-clear-button drpn-clear-button"
						onMousedown={(e: any) => { e.stopPropagation(); }}
						onClick={(e: any) => {
							e.stopPropagation();
							this.clearValue();
						}}
					>
						×
					</span>
				)}
			</div>
		);
	}

	private onInputType(text: string) {
		if (this.useModalMode()) {
			return;
		}

		this._editingText = text;
	}

	private commitInputText() {
		if (this._editingText == null) {
			return;
		}

		const text = this._editingText.trim();
		this._editingText = null;
		if (text === '') {
			this._startDate = null;
			this._endDate = null;
			this._multiDates = [];
			this.raiseChanged();
			return;
		}

		const fmt = this.getFormat();
		if (this.getMode() === 'multi') {
			const parts = text.split(',').map(s => s.trim()).filter(Boolean);
			const parsed: Temporal.PlainDateTime[] = [];
			for (const p of parts) {
				const rangeMatch = p.match(/^(\d+)-(\d+)(\D.*)$/);
				if (rangeMatch) {
					const [
						, fromDay,
						toDay,
						rest,
					] = rangeMatch;
					// Pad to match `dd` width — single-digit "1" alone won't
					// satisfy the strict `dd` token in DateUtils.formatDate.
					const padWidth = fmt.includes('dd') ? 2 : 1;
					const fromStr = fromDay.padStart(padWidth, '0') + rest;
					const toStr = toDay.padStart(padWidth, '0') + rest;
					const fromDate = this.parseDate(fromStr, fmt);
					const toDate = this.parseDate(toStr, fmt);
					if (fromDate != null && toDate != null) {
						let cur = fromDate;
						while (compareDay(cur, toDate) <= 0) {
							parsed.push(cur);
							cur = cur.add({ days: 1 });
						}
						continue;
					}
				}

				const d = this.parseDate(p, fmt);
				if (d != null) {
					parsed.push(d);
				}
			}
			this._multiDates = parsed;
			this.raiseChanged();
			return;
		}

		const sep = this.getSeparator();
		const parts = text.split(sep);
		const startD = this.parseDate(parts[0], fmt);
		if (this.singleDate || this.getMode() === 'single') {
			if (startD != null) {
				this._startDate = startD;
				this._endDate = startD;
				this.raiseChanged();
			}

			return;
		}

		const endD = parts[1] ? this.parseDate(parts[1], fmt) : null;
		if (startD != null && endD != null) {
			this._startDate = startD;
			this._endDate = endD;
			this.raiseChanged();
		}
	}

	private parseDate(text: string, format: string): Temporal.PlainDateTime | null {
		if (!text || !text.trim()) {
			return null;
		}

		try {
			const result = DateUtils.getTemporalFromFormat(text.trim(), format);
			if (result != null) {
				return result;
			}
		} catch {
			/* noop */
		}
		try {
			return TemporalUtils.fromString(text.trim());
		} catch {
			return null;
		}
	}

	// ── Render: month grid ────────────────────────────────────────────────

	private getWeekHead(): VNode[] {
		// `weekdaysOrdered` is pre-built by Intl on first locale lookup and
		// already rotated to match `startOfWeek`. Per render this is 7 plain
		// array reads + 7 VNode creations.
		return getLocaleData().weekdaysOrdered.map(label => <th>{label}</th>);
	}

	private renderMonthHeader(anchor: Temporal.PlainDateTime, position: 'left' | 'right' | 'mobile'): VNode {
		const showPrev = position === 'left' || position === 'mobile';
		const showNext = position === 'right' || position === 'mobile' || this.getEffectiveSingleMonth();
		const monthName = getMonthName(anchor.month);
		const yearName = anchor.year;
		const prevIcon = this.prevIcon ? <i class={this.prevIcon} /> : '<';
		const nextIcon = this.nextIcon ? <i class={this.nextIcon} /> : '>';

		const monthEl = this.monthSelect !== false ? this.renderMonthSelect(anchor) : <span class="month-element">{monthName}</span>;
		const yearEl = !this.hideYearInMonthName && (this.yearSelect !== false ? this.renderYearSelect(anchor) : <span class="year-element">{yearName}</span>);
		return (
			<tr class="caption">
				<th class="drpn-cap-prev">
					{showPrev && (
						<span class="prev" onClick={() => this.gotoPrevMonth(position)}>{prevIcon}</span>
					)}
				</th>
				<th colSpan={5} class="month-name">
					{monthEl}
					{yearEl
						? [
								' ',
								yearEl,
							]
						: null}
				</th>
				<th class="drpn-cap-next">
					{showNext && (
						<span class="next" onClick={() => this.gotoNextMonth(position)}>{nextIcon}</span>
					)}
				</th>
			</tr>
		);
	}

	private renderMonthSelect(anchor: Temporal.PlainDateTime): VNode {
		const minM = (this.minDate && this.minDate.year === anchor.year) ? this.minDate.month : 1;
		const maxM = (this.maxDate && this.maxDate.year === anchor.year) ? this.maxDate.month : 12;
		const options: VNode[] = [];
		for (let m = 1; m <= 12; m++) {
			options.push(<option value={m} disabled={m < minM || m > maxM} selected={m === anchor.month}>{getMonthName(m)}</option>);
		}
		return (
			<span class="select-wrapper select-wrapper-selected-month">
				<span class="drpn-month-label">
					{getMonthName(anchor.month)}
					{' '}
					<span class="select-wrapper-dropdown-icon"><span class="drp-arrow-down" aria-hidden="true" /></span>
				</span>
				<select
					class="drp-date-switch month"
					value={anchor.month}
					onChange={(e: any) => this.onMonthSelectChange(Number(e.target.value))}
				>
					{options}
				</select>
			</span>
		);
	}

	private renderYearSelect(anchor: Temporal.PlainDateTime): VNode {
		const minY = this.minDate ? Math.max(1900, this.minDate.year) : 1900;
		const maxY = this.maxDate ? this.maxDate.year : Math.max(anchor.year + 10, Temporal.Now.plainDateTimeISO().year + 10);
		const options: VNode[] = [];
		for (let y = minY; y <= maxY; y++) {
			options.push(<option value={y} selected={y === anchor.year}>{y}</option>);
		}
		return (
			<span class="select-wrapper select-wrapper-selected-month drpn-year-wrapper">
				<span class="drpn-year-label">
					{anchor.year}
					{' '}
					<span class="select-wrapper-dropdown-icon"><span class="drp-arrow-down" aria-hidden="true" /></span>
				</span>
				<select
					class="drp-date-switch year"
					value={anchor.year}
					onChange={(e: any) => this.onYearSelectChange(Number(e.target.value))}
				>
					{options}
				</select>
			</span>
		);
	}

	private onMonthSelectChange(m: number) {
		this._anchorMonth = this._anchorMonth.with({ month: m, day: 1 });
	}

	private onYearSelectChange(y: number) {
		this._anchorMonth = this._anchorMonth.with({ year: y });
	}

	private gotoPrevMonth(_position: 'left' | 'right' | 'mobile') {
		this._anchorMonth = prevMonthDate(this._anchorMonth);
	}

	private gotoNextMonth(_position: 'left' | 'right' | 'mobile') {
		this._anchorMonth = nextMonthDate(this._anchorMonth);
	}

	// ── Keyboard calendar navigation (WCAG 2.1.1) ─────────────────────────────
	// The text input keeps DOM focus; the active day is tracked via `_kbdFocusMs`
	// and surfaced to assistive tech with aria-activedescendant + a visible
	// `.kbd-focused` ring — the same activedescendant pattern SmartDropdown uses.

	private getInitialKbdFocusMs(): number {
		if (this._startDate != null) {
			return utcEpochAtMidnight(
				this._startDate.year,
				this._startDate.month,
				this._startDate.day,
			);
		}

		// QA_AT-197 — no selection: focus the first SELECTABLE day of the shown month, not the raw
		// month-start (day 1 is often out of bounds / greyed out — e.g. only "today" is available).
		// `minDate` is the picker's first-selectable bound (the shop sets it to the first available day).
		const monthStartMs = utcEpochAtMidnight(
			this._anchorMonth.year,
			this._anchorMonth.month,
			1,
		);
		const minMs = this.minDate
			? utcEpochAtMidnight(
					this.minDate.year,
					this.minDate.month,
					this.minDate.day,
				)
			: null;

		return minMs != null && minMs > monthStartMs ? minMs : monthStartMs;
	}

	/** Scrolls the displayed month(s) so the keyboard-focused day stays in view. */
	private ensureKbdFocusVisible(): void {
		if (this._kbdFocusMs == null) {
			return;
		}

		SHARED_TMP_DATE.setTime(this._kbdFocusMs);
		const year = SHARED_TMP_DATE.getUTCFullYear();
		const month = SHARED_TMP_DATE.getUTCMonth() + 1;
		const focusYm = (year * 12) + (month - 1);
		const anchorYm = (this._anchorMonth.year * 12) + (this._anchorMonth.month - 1);

		// Desktop renders _anchorMonth and the following month; keep focus inside.
		if (focusYm < anchorYm || focusYm > anchorYm + 1) {
			this._anchorMonth = Temporal.PlainDateTime.from({ year, month, day: 1 });
		}
	}

	private moveKbdFocus(deltaDays: number): void {
		// GOBO-354 — focus alone must not paint the day highlight; the FIRST arrow
		// press reveals it on the initial day (selection, else first selectable —
		// QA_AT-197), and only subsequent presses move it.
		if (this._kbdFocusMs == null) {
			this._kbdFocusMs = this.getInitialKbdFocusMs();
		} else {
			this._kbdFocusMs += deltaDays * MS_PER_DAY;
		}

		this.ensureKbdFocusVisible();
		this.$nextTick(() => {
			const root = this.$el as HTMLElement | null;
			root?.querySelector(`[data-time="${this._kbdFocusMs}"]`)?.scrollIntoView({ block: 'nearest' });
		});
	}

	private selectKbdFocusedDay(): void {
		if (this._kbdFocusMs == null) {
			return;
		}

		SHARED_TMP_DATE.setTime(this._kbdFocusMs);
		const date = Temporal.PlainDateTime.from({
			year: SHARED_TMP_DATE.getUTCFullYear(),
			month: SHARED_TMP_DATE.getUTCMonth() + 1,
			day: SHARED_TMP_DATE.getUTCDate(),
		});
		this.dayClicked(date);
	}

	/** Handles arrow/enter while the calendar is open. Returns true if handled. */
	private handleCalendarKeydown(e: KeyboardEvent): boolean {
		// Works both for the popup (driven by the input) and the inline / always-open
		// calendar (driven by the focusable calendar container — no input there).
		const inlineMode = this.alwaysOpen || this.calendarPlacement === 'inline';
		if (!this._isOpen && !inlineMode) {
			return false;
		}

		switch (e.key) {
			case 'ArrowDown':
				e.preventDefault();
				this.moveKbdFocus(7);

				return true;
			case 'ArrowUp':
				e.preventDefault();
				this.moveKbdFocus(-7);

				return true;
			case 'ArrowLeft':
				// Popup mode only: don't hijack horizontal arrows until grid nav is
				// engaged, so the text cursor keeps working while typing a date. The
				// inline calendar has no input — engage immediately (GOBO-354).
				if (this._kbdFocusMs == null && !inlineMode) {
					return false;
				}

				e.preventDefault();
				this.moveKbdFocus(-1);

				return true;
			case 'ArrowRight':
				if (this._kbdFocusMs == null && !inlineMode) {
					return false;
				}

				e.preventDefault();
				this.moveKbdFocus(1);

				return true;
			case 'Enter':
				if (this._kbdFocusMs != null) {
					e.preventDefault();
					this.selectKbdFocusedDay();

					return true;
				}

				return false;
			default:
				return false;
		}
	}

	private renderMonthGrid(
		anchor: Temporal.PlainDateTime,
		layout: 'month1' | 'month2' | 'mobile',
		ctx: RenderCtx,
	): VNode {
		const rows = this.buildMonthRows(anchor, ctx);
		return (
			<table class={`${layout} drpn-table`} cellSpacing="0" cellPadding="0">
				<thead>
					{this.renderMonthHeader(anchor, layout === 'month1' ? 'left' : layout === 'month2' ? 'right' : 'mobile')}
					<tr class="week-name">{this.getWeekHead()}</tr>
				</thead>
				<tbody>{rows}</tbody>
			</table>
		);
	}

	/**
	 * Build the 6×7 day-cell grid for `anchor`'s month using **ms-based cursor
	 * advancement**. Saves ~42 Temporal allocations per month vs `cursor.add()`;
	 * Temporal.PlainDateTime is now constructed only for in-month cells (~30
	 * per month, skipped entirely for hidden lastMonth/nextMonth placeholders).
	 *
	 * Shared by `renderMonthGrid` (desktop, two-month) and
	 * `renderMobileMonthBody` (mobile vertical scroll).
	 */
	private buildMonthRows(anchor: Temporal.PlainDateTime, ctx: RenderCtx): VNode[] {
		const sow = getStartOfWeek();
		const firstOfMonth = anchor.with({ day: 1 });
		const dow = firstOfMonth.dayOfWeek; // Mon=1..Sun=7
		const backDays = sow === 'monday'
			? (dow === 7 ? 6 : dow - 1)
			: (dow % 7); // Sun=0 back, Mon=1, ...

		const anchorYear = anchor.year;
		const anchorMonth = anchor.month;

		// Single ms cursor — no per-cell Temporal allocations for advancement.
		let cursorMs = utcEpochAtMidnight(
			anchorYear,
			anchorMonth,
			1,
		) - backDays * MS_PER_DAY;
		const tmp = SHARED_TMP_DATE;

		const rows: VNode[] = [];
		let sawToMonth = false;

		for (let week = 0; week < 6; week++) {
			const cells: VNode[] = [];
			let allOutAfter = false;
			for (let d = 0; d < 7; d++) {
				tmp.setTime(cursorMs);
				const year = tmp.getUTCFullYear();
				const month = tmp.getUTCMonth() + 1;
				const day = tmp.getUTCDate();
				const inMonth = year === anchorYear && month === anchorMonth;

				if (inMonth) {
					sawToMonth = true;
					// Lazily construct PlainDateTime only for cells that need
					// it (selection state, click/hover handlers, getCell args).
					const cellDate = Temporal.PlainDateTime.from({ year, month, day });
					const cls = this.classifyDay(
						cellDate,
						anchor,
						ctx,
						cursorMs,
					);
					cells.push(this.renderDayCell(
						cellDate,
						cls,
						cursorMs,
					));
				} else {
					// lastMonth/nextMonth cells are display:none in CSS — emit
					// a minimal <td/> placeholder; no Temporal allocation, no
					// classifyDay call, no inner VNode.
					cells.push(<td class="drpn-other-month" />);
					if (week > 0 && sawToMonth) {
						const restOfMonth = year > anchorYear || (year === anchorYear && month > anchorMonth);
						if (restOfMonth) {
							allOutAfter = true;
						}
					}
				}

				cursorMs += MS_PER_DAY;
			}
			rows.push(<tr>{cells}</tr>);
			if (allOutAfter && week >= 4) {
				break;
			}
		}

		return rows;
	}

	private renderDayCell(
		date: Temporal.PlainDateTime,
		cls: ReturnType<DaterangePickerComponent['classifyDay']>,
		time: number,
	): VNode {
		const classes = ['day'];
		classes.push(cls.type);
		classes.push(cls.valid ? 'valid' : 'invalid');
		if (cls.isToday) {
			classes.push('real-today');
		}

		if (cls.isStart || cls.isInMulti) {
			classes.push('checked', 'first-date-selected');
		}

		if (cls.isEnd) {
			classes.push('checked', 'last-date-selected');
		}

		if (cls.inRange) {
			classes.push('checked');
		}

		if (cls.hovering) {
			classes.push('hovering');
		}

		if (cls.hoveringCurrentFirst) {
			classes.push('hovering-current-first');
		}

		if (cls.hoveringCurrentLast) {
			classes.push('hovering-current-last');
		}

		if (cls.hoveringCurrentBefore) {
			classes.push('hovering-current-before');
		}

		if (cls.isMaxDayCap) {
			classes.push('max-day-cap');
		}

		const classStr = classes.join(' ');
		const timeStr = String(time);

		// Build the public `getCell` args object only when the prop is set —
		// otherwise we skip an object allocation per cell on every render.
		let inner: VNode;
		if (this.getCell) {
			const args: DaterangePickerCellRenderArgs = {
				attributes: { 'data-time': timeStr, 'class': classStr },
				day: {
					date,
					day: date.day,
					extraClass: '',
					time,
					tooltip: '',
					type: cls.type,
					valid: cls.valid,
				},
			};
			inner = this.getCell(args);
		} else {
			inner = <div class={classStr} time={timeStr}>{date.day}</div>;
		}

		const baseTdClass = cls.isStart ? 'drpn-td-first' : cls.isEnd ? 'drpn-td-last' : '';
		const tdClass = this._kbdFocusMs === time ? `${baseTdClass} kbd-focused`.trim() : baseTdClass;
		return (
			<td
				id={`${this._kbdUid}-${time}`}
				class={tdClass}
				data-time={time}
				onClick={() => this.dayClicked(date)}
				onMouseenter={(e: MouseEvent) => this.dayHovered(date, e.currentTarget as HTMLElement)}
				onMouseleave={() => { /* leave handled by overall picker mouseleave */ }}
			>
				{inner}
			</td>
		);
	}

	// ── Topbar / footer ───────────────────────────────────────────────────

	private renderTopbar(ctx: RenderCtx): VNode {
		if (ctx.mode === 'multi') {
			const count = this._multiDates.length;
			return (
				<div class="drpn-topbar">
					<span class="drpn-topbar-label">
						{translate('selected')}
						{' '}
						{formatDayCount(count)}
					</span>
				</div>
			);
		}

		if (!this._startDate) {
			return null;
		}

		const startStr = this.getStartFormatted();
		const endStr = this._endDate ? this.getEndFormatted() : '...';
		return (
			<div class="drpn-topbar">
				<span class="drpn-topbar-label">
					{translate('selected')}
					{' '}
					<b class="start-day">{startStr}</b>
					{!this.singleDate && [
						<span class="separator-day">
							{' '}
							{this.getSeparator()}
							{' '}
						</span>,
						<b class="end-day">{endStr}</b>,
					]}
				</span>
			</div>
		);
	}

	private renderFooter(forceShow: boolean): VNode {
		const mode = this.getMode();
		if (mode === 'multi' || forceShow) {
			return (
				<div class="drpn-footer">
					<button
						type="button"
						class="drpn-btn drpn-btn-cancel"
						onClick={() => this.cancelClicked()}
					>
						{this.cancelText ?? translate('cancel')}
					</button>
					<button
						type="button"
						class="drpn-btn drpn-btn-confirm"
						onClick={() => this.confirmClicked()}
					>
						{this.confirmText ?? translate('confirm')}
					</button>
				</div>
			);
		}

		return null;
	}

	private renderFooterInfo(): VNode {
		if (!this.footerInfo) {
			return null;
		}

		return (
			<div class="drpn-footer-info">
				<i class="gp gp-info-circle drpn-footer-info-icon" />
				<span class="drpn-footer-info-text">{this.footerInfo}</span>
			</div>
		);
	}

	private confirmClicked() {
		this.raiseChanged();
		this.close();
	}

	private cancelClicked() {
		// Revert
		if (this.getMode() === 'multi') {
			this._multiDates = (this._multiDatesEntry ?? []).slice();
		} else {
			this.syncFromValue();
		}

		this.close();
	}

	// ── Tooltip ───────────────────────────────────────────────────────────

	private renderTooltip(ctx: RenderCtx): VNode {
		if (ctx.mode !== 'range' || ctx.startMs == null || ctx.endMs != null || ctx.hoverMs == null) {
			return null;
		}

		const days = Math.abs(Math.round((ctx.hoverMs - ctx.startMs) / MS_PER_DAY)) + 1;
		const invalid = this.maxDays && days > this.maxDays;
		let content: VNode | string = '';
		if (invalid && this.maxDaysTooltip) {
			content = this.maxDaysTooltip(this.maxDays);
		} else if (invalid) {
			// Cursor is past the cap. The literal cursor-to-anchor day count
			// (e.g. "32 dní" when hovering on day 32) is misleading because
			// the actual selectable count is clamped to maxDays. Show the cap
			// instead, prefixed with "max " so the user sees "max 31 dní"
			// rather than a count they cannot actually commit. Reuses the
			// consumer's `customTooltip` (locale-aware via PluralizationHelper)
			// or falls back to the picker's Intl-driven `formatDayCount`.
			const cappedText = this.customTooltip
				? this.customTooltip(this.maxDays)
				: formatDayCount(this.maxDays);
			if (typeof cappedText === 'string') {
				content = `max ${cappedText}`;
			} else {
				content = (
					<span>
						max
						{' '}
						{cappedText}
					</span>
				);
			}
		} else if (this.customTooltip) {
			content = this.customTooltip(days);
		} else if (days > 1) {
			content = formatDayCount(days);
		}

		if (!content) {
			return null;
		}

		const style: Record<string, string> = {};
		if (this._hoverCellAnchor) {
			style.left = `${this._hoverCellAnchor.left}px`;
			style.top = `${Math.max(this._hoverCellAnchor.top - 24, 0)}px`;
			style.transform = 'translateX(-50%)';
		}

		return <div class={`drpn-tooltip${invalid ? ' drpn-tooltip-error' : ''}`} style={style}>{content}</div>;
	}

	// ── Time picker ───────────────────────────────────────────────────────

	private renderTime(name: 'time1' | 'time2', date: Temporal.PlainDateTime): VNode {
		const hour = date?.hour ?? 0;
		const minute = date?.minute ?? 0;
		return (
			<div class={`drpn-time ${name}`}>
				<div class="time-current-container">
					<span>
						{translate('time')}
						:
						{' '}
						<span class="hour-val">{String(hour).padStart(2, '0')}</span>
						:
						<span class="minute-val">{String(minute).padStart(2, '0')}</span>
					</span>
				</div>
				<div class="hour">
					<label>
						{getLocaleData().hourLabel}
						:
						{' '}
						<input
							type="range"
							class="hour-range"
							min="0"
							max="23"
							value={hour}
							onInput={(e: any) => this.onTimeChange(
								name,
								'hour',
								Number(e.target.value),
							)}
						/>
					</label>
				</div>
				<div class="minute">
					<label>
						{getLocaleData().minuteLabel}
						:
						{' '}
						<input
							type="range"
							class="minute-range"
							min="0"
							max="59"
							value={minute}
							onInput={(e: any) => this.onTimeChange(
								name,
								'minute',
								Number(e.target.value),
							)}
						/>
					</label>
				</div>
			</div>
		);
	}

	private onTimeChange(
		name: 'time1' | 'time2',
		kind: 'hour' | 'minute',
		val: number,
	) {
		const target = name === 'time1' ? this._startDate : this._endDate;
		if (target == null) {
			return;
		}

		const updated = target.with({ [kind]: val });
		if (name === 'time1') {
			this._startDate = updated;
		} else {
			this._endDate = updated;
		}

		// Emit the change so consumers (e.g. DataTable filters) re-apply
		// with the new time. Without this, slider movements update internal
		// state but the parent never sees them. Guarded so we only fire
		// once both dates are set — otherwise consumers like the DataTable
		// filter would receive a half-formed range and clear themselves.
		if (this._startDate != null && this._endDate != null) {
			this.raiseChanged();
		}
	}

	// ── Render: dropdown content ──────────────────────────────────────────

	private renderDropdownContent(ctx?: RenderCtx): VNode {
		const renderCtx = ctx ?? this.buildRenderCtx();
		const single = this.getEffectiveSingleMonth();
		const month1 = this._anchorMonth.with({ day: 1 });
		const month2 = nextMonthDate(month1);
		const showTopbar = renderCtx.mode !== 'single' || this.enableTime;
		// Inline / always-open calendar has no text input to drive the keyboard, so the
		// sized calendar content becomes the focusable grid: Tab focuses it, arrows move
		// the day, Enter selects (WCAG 2.1.1). The `.drpn-dropdown` wrapper is 0×0 in
		// inline mode so it can't host focus — `.drpn-content` is the real, sized box.
		const kbdGrid = this.alwaysOpen || this.calendarPlacement === 'inline';
		return (
			<div
				class={`drpn-content ${single ? 'drpn-single-month' : 'drpn-two-months'} ${this.enableTime ? 'drpn-with-time' : ''}`}
				tabindex={kbdGrid ? 0 : undefined}
				role={kbdGrid ? 'group' : undefined}
				aria-label={kbdGrid && typeof this.label === 'string' ? this.label : undefined}
				aria-activedescendant={kbdGrid && this._kbdFocusMs != null ? `${this._kbdUid}-${this._kbdFocusMs}` : undefined}
				onKeydown={kbdGrid ? (e: KeyboardEvent) => { this.handleCalendarKeydown(e); } : undefined}
			>
				{showTopbar && this.renderTopbar(renderCtx)}
				<div class="drpn-month-wrapper">
					<div class="drpn-month-wrapper-inner">
						{this.renderMonthGrid(
							month1,
							'month1',
							renderCtx,
						)}
						{!single && <div class="gap" />}
						{!single && this.renderMonthGrid(
							month2,
							'month2',
							renderCtx,
						)}
					</div>
				</div>
				{this.enableTime && (
					<div class="drpn-time-row">
						{this.renderTime('time1', this._startDate)}
						{!this.singleDate && this.renderTime('time2', this._endDate ?? this._startDate)}
					</div>
				)}
				{this.renderTooltip(renderCtx)}
				{this.renderFooter(false)}
				{this.renderFooterInfo()}
			</div>
		);
	}

	// ── Mobile vertical-scroll content ────────────────────────────────────

	private renderMobileVerticalScroll(ctx: RenderCtx): VNode {
		const months: VNode[] = [];

		// In the Airbnb-style verticalScroll layout, render the full
		// `_mvsStartMonth..._mvsEndMonth` window and let `getCell` handle
		// per-day availability via `valid: false`. Stopping at `maxDate` here
		// would shrink the pickable range for products whose maxDate sits
		// inside the current scroll window, breaking lazy-load.
		let cursor = this._mvsStartMonth;
		while (compareMonth(cursor, this._mvsEndMonth) <= 0) {
			if (!this.minDate || compareMonth(cursor, this.minDate) >= 0) {
				const monthCell = (
					<div
						class="drpn-mvs-month"
						data-anchor={`${cursor.year}-${cursor.month}`}
					>
						<div class="drpn-mvs-month-name">
							{getMonthName(cursor.month)}
							{' '}
							{cursor.year}
						</div>
						<table class="drpn-mvs-table" cellSpacing="0" cellPadding="0">
							<tbody>{this.renderMobileMonthBody(cursor, ctx)}</tbody>
						</table>
					</div>
				);
				months.push(monthCell);
			}

			cursor = nextMonthDate(cursor);
		}

		return (
			<div class="drpn-mvs">
				<div class="drpn-mvs-header">
					<div class="drpn-mvs-selectors">
						{this.monthSelect !== false && this.renderMobileMonthSelector()}
						{this.yearSelect !== false && this.renderMobileYearSelector()}
					</div>
				</div>
				<div class="drpn-mvs-weekdays">
					{this.getWeekHead()}
				</div>
				<div class="drpn-mvs-scroll" ref="mvsScroll" onScroll={() => this.onMvsScroll()}>
					{this.renderTopbar(ctx)}
					{months}
				</div>
				{this.renderFooter(true)}
				{this.renderFooterInfo()}
			</div>
		);
	}

	private renderMobileMonthSelector(): VNode {
		const minM = (this.minDate && this.minDate.year === this._mvsAnchor.year) ? this.minDate.month : 1;
		const maxM = (this.maxDate && this.maxDate.year === this._mvsAnchor.year) ? this.maxDate.month : 12;
		const opts: VNode[] = [];
		for (let m = 1; m <= 12; m++) {
			opts.push(<option value={m} disabled={m < minM || m > maxM} selected={m === this._mvsAnchor.month}>{getMonthName(m)}</option>);
		}
		return (
			<span class="drpn-mvs-select-wrap">
				<span class="drpn-mvs-select-label">
					{getMonthName(this._mvsAnchor.month)}
					{' '}
					<span class="drp-arrow-down" aria-hidden="true" />
				</span>
				<select
					class="drpn-mvs-select"
					value={this._mvsAnchor.month}
					onChange={(e: any) => this.scrollToMonth(this._mvsAnchor.year, Number(e.target.value))}
				>
					{opts}
				</select>
			</span>
		);
	}

	private renderMobileYearSelector(): VNode {
		const minY = this.minDate ? Math.max(1900, this.minDate.year) : 1900;
		const maxY = this.maxDate ? this.maxDate.year : Math.max(this._mvsAnchor.year + 5, Temporal.Now.plainDateTimeISO().year + 5);
		const opts: VNode[] = [];
		for (let y = minY; y <= maxY; y++) {
			opts.push(<option value={y} selected={y === this._mvsAnchor.year}>{y}</option>);
		}
		return (
			<span class="drpn-mvs-select-wrap">
				<span class="drpn-mvs-select-label">
					{this._mvsAnchor.year}
					{' '}
					<span class="drp-arrow-down" aria-hidden="true" />
				</span>
				<select
					class="drpn-mvs-select"
					value={this._mvsAnchor.year}
					onChange={(e: any) => this.scrollToMonth(Number(e.target.value), this._mvsAnchor.month)}
				>
					{opts}
				</select>
			</span>
		);
	}

	private scrollToMonth(year: number, month: number) {
		const target = Temporal.PlainDateTime.from({ year, month, day: 1 });
		this._mvsAnchor = target;
		// Ensure the requested month is within the rendered window.
		if (compareMonth(target, this._mvsStartMonth) < 0) {
			this._mvsStartMonth = prevMonthDate(target);
		}

		if (compareMonth(target, this._mvsEndMonth) > 0) {
			this._mvsEndMonth = target.add({ months: 6 });
		}

		this.$nextTick(() => {
			const scroll = this.$refs.mvsScroll as HTMLElement | undefined;
			if (!scroll) {
				return;
			}

			const monthEl = scroll.querySelector(`[data-anchor="${year}-${month}"]`) as HTMLElement | null;
			if (monthEl) {
				this._mvsScrollLock = true;
				scroll.scrollTop = monthEl.offsetTop - 8;
				setTimeout(() => {
					this._mvsScrollLock = false;
				}, 100);
			}
		});
	}

	private onMvsScroll() {
		if (this._mvsScrollLock) {
			return;
		}

		const scroll = this.$refs.mvsScroll as HTMLElement | undefined;
		if (!scroll) {
			return;
		}

		// Update visible-month indicator (which month is currently in viewport)
		const monthEls = Array.from(scroll.querySelectorAll('.drpn-mvs-month')) as HTMLElement[];
		const probeY = scroll.scrollTop + 32; // probe just below sticky weekday header
		for (const m of monthEls) {
			if (m.offsetTop + m.offsetHeight > probeY) {
				const anchor = m.getAttribute('data-anchor');
				if (anchor) {
					const [
						y,
						mo,
					] = anchor.split('-').map(Number);
					if (!Number.isNaN(y) && !Number.isNaN(mo) && (this._mvsAnchor.year !== y || this._mvsAnchor.month !== mo)) {
						this._mvsAnchor = Temporal.PlainDateTime.from({ year: y, month: mo, day: 1 });
					}
				}

				break;
			}
		}

		// Lazy-extend when the user scrolls near the bottom or top
		const distFromBottom = scroll.scrollHeight - (scroll.scrollTop + scroll.clientHeight);
		if (distFromBottom < 800) {
			// `maxDate` is honored at cell-level (greyed-out invalid days);
			// keep extending the rendered window so users can scroll past it.
			this._mvsEndMonth = this._mvsEndMonth.add({ months: 6 });
		}

		if (scroll.scrollTop < 200) {
			const newStart = this._mvsStartMonth.subtract({ months: 6 });
			if (!this.minDate || compareMonth(newStart, this.minDate) >= 0) {
				const before = scroll.scrollHeight;
				this._mvsStartMonth = newStart;
				this._mvsScrollLock = true;
				this.$nextTick(() => {
					const after = scroll.scrollHeight;
					scroll.scrollTop += after - before;
					setTimeout(() => {
						this._mvsScrollLock = false;
					}, 100);
				});
			}
		}
	}

	private renderMobileMonthBody(anchor: Temporal.PlainDateTime, ctx: RenderCtx): VNode[] {
		// Mobile vertical-scroll uses the same grid logic as the desktop layout.
		return this.buildMonthRows(anchor, ctx);
	}

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

	getCssClass(): string {
		const isMobile = this.displayInModal();
		return [
			'daterange-picker-input',
			'drpn-root',
			this.customInputText != null ? 'daterange-picker-input-customtext' : '',
			isMobile ? 'pd-daterange-picker-modal-mode' : '',
			this.cssClass ?? '',
			this.prependIcon != null ? 'input-group-prepend-icon' : '',
			this.appendIcon != null ? 'input-group-append-icon' : '',
		].filter(Boolean).join(' ');
	}

	render() {
		const inline = this.calendarPlacement === 'inline' && !this.useModalMode();
		const showInlineDropdown = (this._isOpen && !this.useModalMode()) || this.alwaysOpen || inline;

		const dropdownPositionClass = (() => {
			switch (this.calendarPlacement) {
				case 'inline': return 'drpn-pos-inline';
				case 'input-container-leftalign': return 'drpn-pos-left';
				case 'input-container-rightalign': return 'drpn-pos-right';
				default: {
					const horizontal = this._dropdownAnchor === 'right' ? 'drpn-pos-right' : 'drpn-pos-default';
					if (this._dropdownVertical === 'up') {
						return `${horizontal} drpn-pos-up`;
					}

					if (this._dropdownVertical === 'fixed') {
						return `${horizontal} drpn-pos-fixed`;
					}

					return horizontal;
				}
			}
		})();

		// Build the render-context once per render. Shared by every day cell,
		// the topbar, and the tooltip — saves ~84 Temporal sameDay/compareDay
		// allocations and an O(N) multi-array scan per day cell.
		const ctx = this.buildRenderCtx();

		return (
			<FormItemWrapper
				label={this.label}
				cssClass={this.getCssClass()}
				mandatory={this.mandatory}
				wrap={this.wrap}
				appendIcon={this.appendIcon}
				prependIcon={this.prependIcon}
				hint={this.hint}
				marginType={this.marginType}
				appendClicked={this.appendClicked}
				prependClicked={this.prependClicked}
				prependIconClicked={this.prependIconClicked}
				appendIconClicked={this.appendIconClicked}
				validationState={this.validationState}
				labelButtons={this.labelButtons}
				subtitle={this.subtitle}
			>
				<div class="drpn-wrap" onMouseleave={() => this.clearHover()}>
					{this.renderInput()}
					{showInlineDropdown && (
						<div
							class={`date-range-picker-root drpn-dropdown ${dropdownPositionClass} ${this.pickerRootCssClass ?? ''}`}
							onClick={(e: MouseEvent) => e.stopPropagation()}
						>
							{this.mobileVariant === 'verticalScroll' && this.displayInModal()
								? this.renderMobileVerticalScroll(ctx)
								: this.renderDropdownContent(ctx)}
						</div>
					)}
				</div>
				{this.useModalMode() && (
					<Modal
						ref="drpnModal"
						mobileMode={ModalMobileMode.BottomSheetModal}
						title={this.label as any}
						cssClass="daterangepicker-modal pd-modal-mobile-input drpn-modal"
					>
						<div class="modal-body drpn-modal-body">
							{this.getMobileVariant() === 'verticalScroll'
								? this.renderMobileVerticalScroll(ctx)
								: this.renderDropdownContent(ctx)}
						</div>
					</Modal>
				)}
			</FormItemWrapper>
		);
	}
}

const DaterangePicker = toNative(DaterangePickerComponent);
export default DaterangePicker;
