import { EditorSelection, EditorState, Extension, StateEffect, StateField } from '@codemirror/state';
import { EditorView, ViewPlugin, ViewUpdate } from '@codemirror/view';
import { EditorSettings, OnEventCallback } from '../../types';
import getSearchState from '../utils/getSearchState';
import { EditorEventType } from '../../events';
import { search, searchPanelOpen, SearchQuery, setSearchQuery } from '@codemirror/search';
import announceSearchMatch from '../vendor/announceSearchMatch';

type CancelEvent = { cancelled: boolean };

const scanForFirstMatch = async (
	state: EditorState,
	query: SearchQuery,
	startPosition: number,
	delayFunction: ()=> Promise<void>,
	cancelEvent: CancelEvent,
) => {
	if (cancelEvent.cancelled) return null;

	const pageSizeChars = 40_000;
	let nextStartPosition = startPosition;

	const nextCursor = () => {
		if (nextStartPosition >= state.doc.length) {
			return null;
		}

		let endPosition = Math.min(nextStartPosition + pageSizeChars, state.doc.length);
		const endPositionLine = state.doc.lineAt(endPosition);
		// Always search up to the end of the current line to avoid getting partial matches.
		endPosition = endPositionLine.to;

		const cursor = query.getCursor(state, nextStartPosition, endPosition);
		nextStartPosition = endPosition;
		return cursor;
	};

	const nextCursorAndWait = async () => {
		const result = nextCursor();
		await delayFunction();
		return result;
	};

	for (let cursor = nextCursor(); !!cursor && !cancelEvent.cancelled; cursor = await nextCursorAndWait()) {
		const match = cursor.next();
		if (match?.value && match.value.to && match.value.from !== match.value.to) {
			return match.value;
		}
	}
	return null;
};

// Included in a transaction if it was caused by the auto-scroll-to-next-match logic
const autoMatchAnnotation = StateEffect.define<boolean>();

const autoMatchSearchStartField = StateField.define<number>({
	create: (state) => state.selection.main.from,
	update: (lastValue, viewUpdate) => {
		const changedByAutoMatch = viewUpdate.effects.some(effect => effect.is(autoMatchAnnotation));
		const sameSelection = viewUpdate.startState.selection.eq(viewUpdate.newSelection);
		const noSignificantChanges = sameSelection && !viewUpdate.docChanged;

		if (changedByAutoMatch || noSignificantChanges) {
			return lastValue;
		}
		return viewUpdate.newSelection.main.from;
	},
});

const autoScrollToMatchPlugin = ViewPlugin.fromClass(class {
	private _lastCancelEvent: CancelEvent = { cancelled: false };
	public constructor(private _view: EditorView) { }

	private async handleScrollOnQueryChange_(
		query: SearchQuery,
		state: EditorState,
		startState: EditorState,
		cancelEvent: CancelEvent,
	) {
		const isOpenSearchPanelEvent = () => searchPanelOpen(startState) && !searchPanelOpen(state);
		if (
			!query || query.search.length === 0
			// Avoid auto-scrolling to the search result when first opening the search panel
			|| isOpenSearchPanelEvent()
		) {
			return;
		}
		const getFirstMatchAfter = async (pos: number) => {
			const delayFunction = () => {
				return new Promise<void>(resolve => {
					requestAnimationFrame(() => resolve());
				});
			};
			return await scanForFirstMatch(
				state, query, pos, delayFunction, cancelEvent,
			);
		};

		const searchStart = state.field(autoMatchSearchStartField);
		const firstMatchAfterSelection = await getFirstMatchAfter(searchStart);
		const targetMatch = firstMatchAfterSelection ?? await getFirstMatchAfter(0);

		if (targetMatch && targetMatch.from >= 0 && !cancelEvent.cancelled) {
			this._view.dispatch({
				selection: EditorSelection.single(targetMatch.from, targetMatch.to),
				effects: [
					// Mark this transaction as an auto-match. This allows listeners to
					// process the transaction differently.
					autoMatchAnnotation.of(true),

					EditorView.scrollIntoView(targetMatch.from),
					announceSearchMatch(state, targetMatch),
				],
				userEvent: 'select.search',
			});
		}
	}

	public async update(update: ViewUpdate) {
		let lastQueryUpdate: StateEffect<SearchQuery>|null = null;
		for (const tr of update.transactions) {
			const queryUpdate = tr.effects.find(e => e.is(setSearchQuery));
			if (queryUpdate) {
				lastQueryUpdate = queryUpdate;
			}
		}

		const cancelOngoingSearch = () => {
			this._lastCancelEvent.cancelled = true;
		};

		const newCancelEvent = () => {
			cancelOngoingSearch();
			const cancelEvent = { cancelled: false };
			this._lastCancelEvent = cancelEvent;
			return cancelEvent;
		};

		if (!searchPanelOpen(update.state)) {
			cancelOngoingSearch();
		} else if (lastQueryUpdate) {
			void this.handleScrollOnQueryChange_(
				lastQueryUpdate.value, update.state, update.startState, newCancelEvent(),
			);
		}
	}
}, {
	provide: () => autoMatchSearchStartField,
});

export const searchChangeSourceEffect = StateEffect.define<string>();

const searchExtension = (onEvent: OnEventCallback, settings: EditorSettings): Extension => {
	const onSearchDialogUpdate = (state: EditorState, changeSources: string[]) => {
		const newSearchState = getSearchState(state);

		onEvent({
			kind: EditorEventType.UpdateSearchDialog,
			searchState: newSearchState,
			changeSources,
		});
	};

	return [
		search(settings.useExternalSearch ? {
			createPanel(_editor: EditorView) {
				return {
					// The actual search dialog is implemented with react native,
					// use a dummy element.
					dom: document.createElement('div'),
					mount() { },
					destroy() { },
				};
			},
		} : undefined),

		// On mobile, scrolling is handled externally (page-level native scroll), so
		// .cm-scroller has no internal scroll. A @codemirror/view upgrade (6.35→6.41)
		// added rect-clipping after each failed scroll attempt, which prevents the
		// fallback window.scrollBy from firing. Use native element.scrollIntoView to
		// fix findNext/findPrevious and GoDocEnd.
		settings.useExternalSearch ? EditorView.scrollHandler.of((view, range) => {
			const { node } = view.domAtPos(range.head);
			const el = node.nodeType === Node.ELEMENT_NODE ? node as Element : node.parentElement;
			if (!el) return false;
			el.scrollIntoView({ block: 'nearest', inline: 'nearest' });
			return true;
		}) : [],

		autoScrollToMatchPlugin,

		EditorState.transactionExtender.of((tr) => {
			if (tr.effects.some(e => e.is(setSearchQuery)) || searchPanelOpen(tr.state) !== searchPanelOpen(tr.startState)) {
				const changeSourceEffects = tr.effects.filter(effect => effect.is(searchChangeSourceEffect));
				const changeSources = changeSourceEffects.map(effect => effect.value);

				onSearchDialogUpdate(tr.state, changeSources);
			}
			return null;
		}),
	];
};

export default searchExtension;
