import {
	type KeyboardEvent,
	type PointerEvent,
	useEffect,
	useLayoutEffect,
	useMemo,
	useRef,
	useState,
} from 'react';

import type { AnyProfileView } from '@atcute/bluesky';
import { type Token, tokenize } from '@atcute/bluesky-search-parser';
import { ok } from '@atcute/client';
import type { ActorIdentifier } from '@atcute/lexicons';

import { mapDefined } from '@mary/array-fns';
import {
	addDays,
	addMonths,
	differenceInCalendarDays,
	differenceInCalendarMonths,
	isAfterDate,
	isBeforeDate,
	isSameCalendarMonth,
	startOfDay,
	startOfMonth,
	startOfWeek,
} from '@mary/date-fns';

import { Autocomplete } from '@base-ui/react/autocomplete';

import { isInvalidHandle } from '#/lib/display-names';
import { useConstant } from '#/lib/hooks/use-constant';

import { focusSearch } from '#/state/events';
import {
	addSearchHistoryEntry,
	removeSearchHistoryEntry,
	useSearchHistory,
} from '#/state/preferences/search-history';
import { useSearchActorAutocompleteQuery } from '#/state/queries/actor-autocomplete';
import { useProfileQuery, useProfilesQuery } from '#/state/queries/profile';
import { getClients, useSession } from '#/state/session';

import * as SearchField from '#/components/forms/SearchField';

import { m } from '#/paraglide/messages';
import { getRouter } from '#/router';
import type { SearchHistoryEntry } from '#/storage';

import { CalendarBody } from './CalendarBody';
import { buildResult, CALENDAR_DAY_COUNT, type InteractiveItem, interactiveItems } from './model';
import {
	classifyActiveToken,
	findActiveToken,
	getDateConstraints,
	getOperatorSuggestions,
	type OperatorName,
	parseStartDate,
	splitFilters,
} from './query-syntax';
import { Row } from './Row';
import * as styles from './SearchAutocomplete.css';

/** returns the default-mode profile query, or empty when operators are present. */
const defaultProfileQuery = (tokens: Token[]): string => {
	const [remains, filters] = splitFilters(tokens);
	if (filters.size > 0) {
		return '';
	}

	return remains
		.map((token) => token.value)
		.join('')
		.trim();
};

const rememberGotoProfile = async (actor: ActorIdentifier) => {
	const { appview } = getClients();
	const profile = await ok(
		appview.get('app.bsky.actor.getProfile', {
			params: { actor },
		}),
	);
	addSearchHistoryEntry({ kind: 'profile', did: profile.did });
};

/** turns a suggestion into the plain string Base UI uses for its accessible value. */
const itemToStringValue = (item: InteractiveItem): string => {
	switch (item.kind) {
		case 'date': {
			return item.iso;
		}
		case 'goto': {
			return item.name;
		}
		case 'link': {
			return item.path;
		}
		case 'operator': {
			return item.operator.name;
		}
		case 'operatorValue': {
			return `${item.op}:${item.value}`;
		}
		case 'profile': {
			return item.profile.handle;
		}
		case 'recentProfile': {
			return item.profile.handle;
		}
		case 'recentQuery': {
			return item.query;
		}
		case 'search': {
			return item.query;
		}
	}
};

const clampToConstraints = (date: Date, constraints: ReturnType<typeof getDateConstraints> | null): Date => {
	if (!constraints) {
		return date;
	}
	const { max, min } = constraints;
	if (min !== undefined && isBeforeDate(startOfDay(date), startOfDay(min))) {
		return startOfDay(min);
	}
	if (max !== undefined && isAfterDate(startOfDay(date), startOfDay(max))) {
		return startOfDay(max);
	}
	return date;
};

/** start/end offsets of the active token within the whole query. */
const splicePosition = (tokens: Token[], tokenIndex: number): [start: number, end: number] => {
	let start = 0;
	for (let i = 0; i < tokenIndex; i++) {
		start += tokens[i]!.value.length;
	}

	return [start, start + tokens[tokenIndex]!.value.length];
};

const NO_FIXED_FILTERS: readonly OperatorName[] = [];
const NO_HISTORY: readonly SearchHistoryEntry[] = [];

type SearchAutocompleteProps = {
	fixedFilters?: readonly OperatorName[];
	onNavigate: (path: string) => void;
	onNavigateToProfile: (profile: AnyProfileView) => void;
	onSubmit: (query: string) => void;
};

type SearchAutocompleteFieldProps = SearchAutocompleteProps & {
	autoFocus?: boolean;
	eager?: boolean;
	/** seed text for the field; re-seeds the field whenever it changes (e.g. navigating between searches). */
	initialQuery?: string;
	/** placeholder shown while the field is empty. */
	placeholder?: string;
};

/**
 * a search entry point that lazily mounts the autocomplete search field on first engagement unless eager.
 *
 * @param autoFocus whether to focus an eager field on mount
 * @param eager mount the live field immediately instead of the lazy placeholder
 * @param fixedFilters operators supplied outside the editable query
 * @param initialQuery text to seed the live field with
 * @param onNavigate callback to navigate to an in-app route path
 * @param onNavigateToProfile callback to open a profile chosen from the typeahead
 * @param onSubmit callback to run a search for the query
 * @param placeholder placeholder shown while the field is empty
 */
export function SearchAutocomplete({
	autoFocus,
	eager,
	initialQuery,
	placeholder,
	...props
}: SearchAutocompleteFieldProps) {
	const [active, setActive] = useState(eager ?? false);
	const placeholderRef = useRef<HTMLInputElement | null>(null);

	// the active field owns focus after the lazy placeholder is replaced.
	useEffect(() => {
		return focusSearch.subscribe(() => {
			placeholderRef.current?.focus();
		});
	}, []);

	if (active) {
		return (
			<ActiveSearchAutocomplete
				{...props}
				autoFocus={autoFocus || !eager}
				initialQuery={initialQuery}
				placeholder={placeholder}
			/>
		);
	}

	return (
		<SearchField.Root>
			<SearchField.Icon />
			<SearchField.Input
				onFocus={() => setActive(true)}
				placeholder={placeholder ?? m['common.action.search']()}
				ref={placeholderRef}
			/>
		</SearchField.Root>
	);
}

/**
 * live search field that parses the query at the caret to switch between a default search/operator view, an
 * actor list, and a calendar grid.
 */
function ActiveSearchAutocomplete({
	autoFocus,
	fixedFilters = NO_FIXED_FILTERS,
	initialQuery = '',
	onNavigate,
	onNavigateToProfile,
	onSubmit,
	placeholder,
}: SearchAutocompleteProps & { autoFocus: boolean; initialQuery?: string; placeholder?: string }) {
	const { currentAccount } = useSession();
	const { data: meProfile } = useProfileQuery({ did: currentAccount?.did });

	const scoped = fixedFilters.length > 0;

	const storedHistory = useSearchHistory();
	// fixed filters invalidate global history.
	const history = scoped ? NO_HISTORY : storedHistory;
	const recentProfileDids = mapDefined(history, (entry) =>
		entry.kind === 'profile' ? entry.did : undefined,
	);
	const { data: recentProfileData, isPending: recentProfilesPending } = useProfilesQuery({
		dids: recentProfileDids,
	});
	const recentProfiles = new Map(
		(recentProfileData?.profiles ?? []).map((profile) => [profile.did, profile]),
	);

	const inputRef = useRef<HTMLInputElement | null>(null);
	const fieldRef = useRef<HTMLDivElement | null>(null);
	const popupRef = useRef<HTMLDivElement | null>(null);
	const actionsRef = useRef<Autocomplete.Root.Actions | null>(null);
	const highlightedRef = useRef<InteractiveItem | undefined>(undefined);
	const highlightedIndexRef = useRef<number>(-1);
	// target cell for keyboard rollover after a month change.
	const pendingHighlightRef = useRef<number | null>(null);

	useEffect(() => {
		return focusSearch.subscribe(() => {
			inputRef.current?.focus();
		});
	}, []);

	useLayoutEffect(() => {
		if (autoFocus) {
			inputRef.current?.focus();
		}
	}, [autoFocus]);

	const [query, setQuery] = useState(initialQuery);
	const [caret, setCaret] = useState(initialQuery.length);
	const [open, setOpen] = useState(false);
	const [visibleMonth, setVisibleMonth] = useState(() => startOfMonth(new Date()));
	const [trackedSnapKey, setTrackedSnapKey] = useState<string | null>(null);

	// reset the field when navigation changes its seed query.
	const [prevInitialQuery, setPrevInitialQuery] = useState(initialQuery);
	if (initialQuery !== prevInitialQuery) {
		setPrevInitialQuery(initialQuery);
		setQuery(initialQuery);
		setCaret(initialQuery.length);
		setOpen(false);
	}

	// keep calendar calculations on one clock snapshot.
	const today = useConstant(() => new Date());

	// keep parsed query data stable for the date-picker callbacks.
	const tokens = useMemo(() => tokenize(query), [query]);
	const active = useMemo(() => findActiveToken(tokens, caret), [tokens, caret]);
	const mode = useMemo(() => classifyActiveToken(active), [active]);
	const operatorSuggestions = getOperatorSuggestions(tokens, active, fixedFilters);
	const fromActive =
		fixedFilters.includes('from') ||
		tokens.some(
			(token, index) =>
				index !== active?.tokenIndex && token.type === 'word' && token.value.startsWith('from:'),
		);

	// the selectable date range for the active `since`/`until` picker (day-granular bounds).
	const dateConstraints = useMemo(
		() => (mode.kind === 'date' ? getDateConstraints(tokens, mode.op, today) : null),
		[mode, tokens, today],
	);

	// test dates against day-granular picker bounds.
	const isSelectableDate = (date: Date): boolean => {
		if (!dateConstraints) {
			return true;
		}
		const day = startOfDay(date);
		const { max, min } = dateConstraints;
		return (
			(min === undefined || !isBeforeDate(day, startOfDay(min))) &&
			(max === undefined || !isAfterDate(day, startOfDay(max)))
		);
	};

	// open the calendar on the typed month or the nearest selectable month.
	let snapDate: Date | null = null;
	let snapKey: string | null = null;
	if (mode.kind === 'date') {
		snapDate = parseStartDate(mode.query) ?? clampToConstraints(today, dateConstraints);
		snapKey = `${mode.op}:${snapDate.getFullYear()}-${snapDate.getMonth()}`;
	}
	if (snapKey !== trackedSnapKey) {
		setTrackedSnapKey(snapKey);
		if (snapDate) {
			setVisibleMonth(startOfMonth(snapDate));
		}
	}

	// actor mode searches handles; default mode searches free text.
	const profileQuery =
		mode.kind === 'actor'
			? mode.query.replace(/^@/, '')
			: mode.kind === 'default'
				? defaultProfileQuery(tokens)
				: '';
	// keep the signed-in account in the actor query so loading does not flash an empty list.
	const { data: profileData } = useSearchActorAutocompleteQuery({
		limit: mode.kind === 'actor' ? 10 : 6,
		query: profileQuery,
		self: mode.kind === 'actor' ? meProfile : undefined,
	});
	// default matches disappear when the free text is empty; actor matches do not.
	const profiles = mode.kind === 'actor' || profileQuery ? (profileData ?? []) : [];

	const result = buildResult({
		constraints: dateConstraints ?? {},
		fromActive,
		history,
		mode,
		operators: operatorSuggestions,
		profiles,
		query,
		recentProfiles,
		// reserve a row for unresolved recent profiles while they load.
		recentProfilesPending,
		scoped,
		today,
		visibleMonth,
	});
	const items = interactiveItems(result);

	// choose the initial calendar highlight.
	const isPartialDate = mode.kind === 'date' && mode.query !== '';
	useEffect(() => {
		if (mode.kind !== 'date' || !open) {
			return;
		}
		const actions = actionsRef.current;
		if (!actions) {
			return;
		}

		let targetIndex = pendingHighlightRef.current;
		if (targetIndex != null) {
			pendingHighlightRef.current = null;
		} else {
			const preferred =
				!isPartialDate && isSameCalendarMonth(visibleMonth, today) ? today : startOfMonth(visibleMonth);
			// keep the initial highlight selectable.
			const target = clampToConstraints(preferred, dateConstraints);
			targetIndex = differenceInCalendarDays(target, startOfWeek(startOfMonth(visibleMonth)));
		}

		if (targetIndex >= 0 && targetIndex < CALENDAR_DAY_COUNT) {
			actions.setActiveIndex(targetIndex);
		}
	}, [dateConstraints, isPartialDate, mode.kind, open, today, visibleMonth]);

	const syncCaret = () => {
		const el = inputRef.current;
		if (el) {
			setCaret(el.selectionEnd ?? el.value.length);
		}
	};

	const reset = () => {
		setQuery('');
		setOpen(false);
		inputRef.current?.blur();
	};

	/** splices `replacement` over the token under the caret, keeping focus and undo history intact. */
	const replaceToken = (replacement: string) => {
		const el = inputRef.current;
		if (!el) {
			return;
		}

		el.focus();

		if (!active) {
			el.setSelectionRange(0, el.value.length);
			document.execCommand('insertText', false, replacement);
		} else {
			const [start, end] = splicePosition(tokens, active.tokenIndex);
			const nextToken = tokens[active.tokenIndex + 1];
			const prevToken = tokens[active.tokenIndex - 1];

			// include a trailing space in the replacement range.
			const extra = nextToken?.type === 'whitespace' ? nextToken.value.length : 0;
			// separate adjacent tokens when needed.
			const text = prevToken && prevToken.type !== 'whitespace' ? ' ' + replacement : replacement;

			el.setSelectionRange(start, end + extra);
			document.execCommand('insertText', false, text);
		}
	};

	// navigate within the date bounds.
	const goToMonth = (delta: number) =>
		setVisibleMonth((month) => {
			const next = startOfMonth(addMonths(month, delta));
			const lower = dateConstraints?.min ? startOfMonth(dateConstraints.min) : null;
			const upper = dateConstraints?.max ? startOfMonth(dateConstraints.max) : null;
			if (lower && isBeforeDate(next, lower)) {
				return lower;
			}
			if (upper && isAfterDate(next, upper)) {
				return upper;
			}
			return next;
		});

	const navigate = (path: string) => {
		onNavigate(path);
		reset();
	};

	const selectProfile = (profile: AnyProfileView) => {
		addSearchHistoryEntry({ kind: 'profile', did: profile.did });
		onNavigateToProfile(profile);
		reset();
	};

	const submit = (next: string) => {
		const trimmed = next.trim();
		if (!trimmed) {
			return;
		}
		addSearchHistoryEntry({ kind: 'query', query: trimmed });
		onSubmit(trimmed);
		reset();
	};

	// handle calendar month navigation before Base UI's grid navigation.
	const onInputKeyDownCapture = (event: KeyboardEvent<HTMLInputElement>) => {
		if (mode.kind !== 'date') {
			return;
		}

		if (event.key === 'PageUp' || event.key === 'PageDown') {
			event.preventDefault();
			event.stopPropagation();
			const step = event.key === 'PageUp' ? -1 : 1;
			goToMonth(event.shiftKey ? step * 12 : step);
			return;
		}

		// roll over to the adjacent month at grid edges.
		const index = highlightedIndexRef.current;
		if (index < 0) {
			return;
		}
		const gridStart = startOfWeek(startOfMonth(visibleMonth));
		const current = addDays(gridStart, index);

		let target: Date | null = null;
		if (event.key === 'ArrowUp' && index < 7) {
			target = addDays(current, -7);
		} else if (event.key === 'ArrowDown' && index >= CALENDAR_DAY_COUNT - 7) {
			target = addDays(current, 7);
		} else if (event.key === 'ArrowLeft' && index === 0) {
			target = addDays(current, -1);
		} else if (event.key === 'ArrowRight' && index === CALENDAR_DAY_COUNT - 1) {
			target = addDays(current, 1);
		}

		if (target) {
			// do not cross the date bounds.
			if (!isSelectableDate(target)) {
				event.preventDefault();
				event.stopPropagation();
				return;
			}
			event.preventDefault();
			event.stopPropagation();
			const targetMonth = startOfMonth(target);
			pendingHighlightRef.current = differenceInCalendarDays(target, startOfWeek(targetMonth));
			goToMonth(differenceInCalendarMonths(targetMonth, visibleMonth));
		}
	};

	// route item-press actions through the highlighted item.
	const commit = (item: InteractiveItem) => {
		switch (item.kind) {
			case 'date': {
				replaceToken(`${item.op}:${item.iso} `);
				break;
			}
			case 'goto': {
				void rememberGotoProfile(item.name).catch(() => {});
				navigate(getRouter().href(item.target));
				break;
			}
			case 'link': {
				navigate(item.path);
				break;
			}
			case 'operator': {
				replaceToken(`${item.operator.name}:`);
				break;
			}
			case 'operatorValue': {
				replaceToken(`${item.op}:${item.value} `);
				break;
			}
			case 'profile': {
				if (item.op) {
					// use `me` for the current account and DID for invalid handles.
					let actor: string;
					if (item.profile.did === currentAccount?.did) {
						actor = 'me';
					} else if (isInvalidHandle(item.profile.handle)) {
						actor = item.profile.did;
					} else {
						actor = item.profile.handle;
					}
					replaceToken(`${item.op}:${actor} `);
				} else {
					selectProfile(item.profile);
				}
				break;
			}
			case 'recentProfile': {
				selectProfile(item.profile);
				break;
			}
			case 'recentQuery': {
				submit(item.query);
				break;
			}
			case 'search': {
				submit(item.query);
				break;
			}
		}
	};

	const onInputKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
		// highlighted items handle Enter; otherwise submit the raw query.
		if (event.key === 'Enter' && !highlightedRef.current) {
			event.preventDefault();
			submit(query);
		}
	};

	// touch and pen have no hover, so highlight the pressed row before item-press.
	const onListPointerDown = (event: PointerEvent<HTMLDivElement>) => {
		if (event.pointerType === 'mouse') {
			return;
		}
		const target = event.target;
		const pressed = target instanceof Element ? target.closest('[role="option"], [role="gridcell"]') : null;
		if (!pressed) {
			return;
		}
		const cells = event.currentTarget.querySelectorAll('[role="option"], [role="gridcell"]');
		const index = Array.prototype.indexOf.call(cells, pressed);
		if (index >= 0) {
			actionsRef.current?.setActiveIndex(index);
		}
	};

	return (
		<Autocomplete.Root
			actionsRef={actionsRef}
			// preselect matches, but let the date picker manage its own highlight.
			autoHighlight={result.kind !== 'date' && query.trim() !== '' ? 'always' : false}
			// keep the popup mounted only while open.
			autoUnmount
			filter={null}
			grid={result.kind === 'date'}
			items={items}
			itemToStringValue={itemToStringValue}
			onItemHighlighted={(item, details) => {
				highlightedRef.current = item;
				highlightedIndexRef.current = details.index;
			}}
			onOpenChange={(next, details) => {
				// keep the popup open when an item only refines the query.
				if (!next && details.reason === 'item-press') {
					return;
				}
				// keep operator hints visible when the field is cleared.
				if (!next && details.reason === 'input-clear') {
					return;
				}
				setOpen(next);
			}}
			onValueChange={(next, details) => {
				// dispatch item presses instead of treating their labels as typed input.
				if (details.reason === 'item-press') {
					const item = highlightedRef.current;
					if (item) {
						commit(item);
					}
					return;
				}
				setQuery(next);
				syncCaret();
				// reopen the operator list when the cleared field was dismissed.
				if (next === '') {
					setOpen(true);
				}
			}}
			open={open}
			value={query}
		>
			<SearchField.Root ref={fieldRef}>
				<SearchField.Icon />
				<Autocomplete.Input
					onBlur={(event) => {
						// close on focus-out unless focus moved within the field or popup.
						const next = event.relatedTarget;
						if (fieldRef.current?.contains(next) || popupRef.current?.contains(next)) {
							return;
						}
						setOpen(false);
					}}
					onClick={syncCaret}
					onFocus={() => {
						setOpen(true);
						syncCaret();
					}}
					onKeyDown={onInputKeyDown}
					onKeyDownCapture={onInputKeyDownCapture}
					onKeyUp={syncCaret}
					placeholder={placeholder ?? m['common.action.search']()}
					ref={inputRef}
					render={<SearchField.Input />}
				/>
				<Autocomplete.Clear render={<SearchField.Clear label={m['common.search.action.clear']()} />} />
			</SearchField.Root>
			<Autocomplete.Portal>
				<Autocomplete.Positioner align="end" anchor={fieldRef} className={styles.positioner} sideOffset={6}>
					<Autocomplete.Popup className={styles.popup} ref={popupRef}>
						<Autocomplete.List className={styles.list} onPointerDown={onListPointerDown}>
							{result.kind === 'date' ? (
								<CalendarBody days={result.days} onGoToMonth={goToMonth} visibleMonth={result.visibleMonth} />
							) : (
								result.rows.map((row) => (
									<Row key={row.key} onRemoveRecent={removeSearchHistoryEntry} row={row} />
								))
							)}
						</Autocomplete.List>
					</Autocomplete.Popup>
				</Autocomplete.Positioner>
			</Autocomplete.Portal>
		</Autocomplete.Root>
	);
}
