/*!
 * Jodit Editor (https://xdsoft.net/jodit/)
 * Released under MIT see LICENSE.txt in the project root for license information.
 * Copyright (c) 2013-2020 Valeriy Chupurnov. All rights reserved. https://xdsoft.net
 */

import './search.less';

import { Config } from '../../config';
import * as consts from '../../core/constants';
import { MODE_WYSIWYG } from '../../core/constants';
import { Dom } from '../../core/dom';
import { Plugin } from '../../core/plugin';
import { ISelectionRange, markerInfo, IJodit, Nullable } from '../../types';
import { Icon } from '../../core/ui';
import { refs, trim } from '../../core/helpers';

declare module '../../config' {
	interface Config {
		/**
		 * Enable custom search plugin
		 * ![search](https://user-images.githubusercontent.com/794318/34545433-cd0a9220-f10e-11e7-8d26-7e22f66e266d.gif)
		 */
		useSearch: boolean;
		// searchByInput: boolean,
	}
}

Config.prototype.useSearch = true;

/**
 * Search plugin. it is used for custom search in text
 * ![search](https://user-images.githubusercontent.com/794318/34545433-cd0a9220-f10e-11e7-8d26-7e22f66e266d.gif)
 *
 * @example
 * ```typescript
 * var jodit = new Jodit('#editor', {
 *  useSearch: false
 * });
 * // or
 * var jodit = new Jodit('#editor', {
 *  disablePlugins: 'search'
 * });
 * ```
 */
export class search extends Plugin {
	static getSomePartOfStringIndex(
		needle: string,
		haystack: string,
		start: boolean = true
	): number | false {
		return this.findSomePartOfString(needle, haystack, start, true) as
			| number
			| false;
	}

	static findSomePartOfString(
		needle: string,
		haystack: string,
		start: boolean = true,
		getIndex: boolean = false
	): boolean | string | number {
		needle = trim(
			needle.toLowerCase().replace(consts.SPACE_REG_EXP(), ' ')
		);
		haystack = haystack.toLowerCase();

		let i: number = start ? 0 : haystack.length - 1,
			needleStart: number = start ? 0 : needle.length - 1,
			tmpEqualLength: number = 0,
			startAtIndex: number | null = null;

		const inc = start ? 1 : -1,
			tmp: string[] = [];

		for (; haystack[i] !== undefined; i += inc) {
			const some: boolean = needle[needleStart] === haystack[i];
			if (
				some ||
				(startAtIndex !== null &&
					consts.SPACE_REG_EXP().test(haystack[i]))
			) {
				if (startAtIndex === null || !start) {
					startAtIndex = i;
				}

				tmp.push(haystack[i]);

				if (some) {
					tmpEqualLength += 1;
					needleStart += inc;
				}
			} else {
				startAtIndex = null;
				tmp.length = 0;
				tmpEqualLength = 0;
				needleStart = start ? 0 : needle.length - 1;
			}

			if (tmpEqualLength === needle.length) {
				return getIndex ? (startAtIndex as number) : true;
			}
		}

		if (getIndex) {
			return startAtIndex ?? false;
		}

		if (tmp.length) {
			return start ? tmp.join('') : tmp.reverse().join('');
		}

		return false;
	}

	private template = `<div class="jodit-search">
			<div class="jodit-search__box">
				<div class="jodit-search__inputs">
					<input data-ref="query" tabindex="0" placeholder="${this.j.i18n(
						'Search for'
					)}" type="text"/>
					<input data-ref="replace" tabindex="0" placeholder="${this.j.i18n(
						'Replace with'
					)}" type="text"/>
				</div>
				<div class="jodit-search__counts">
					<span data-ref="counter-box">0/0</span>
				</div>
				<div class="jodit-search__buttons">
					<button data-ref="next" tabindex="0" type="button">${Icon.get(
						'angle-down'
					)}</button>
					<button data-ref="prev" tabindex="0" type="button">${Icon.get(
						'angle-up'
					)}</button>
					<button data-ref="cancel" tabindex="0" type="button">${Icon.get(
						'cancel'
					)}</button>
					<button data-ref="replace-btn" tabindex="0" type="button" class="jodit-ui-button">${this.j.i18n(
						'Replace'
					)}</button>
				</div>
			</div>
		</div>`;

	private isOpened: boolean = false;

	private selInfo: Nullable<markerInfo[]> = null;
	private current: Nullable<Node> = null;

	private eachMap = (
		node: Node,
		callback: (elm: Node) => boolean,
		next: boolean
	) => {
		Dom.findWithCurrent(
			node,
			(child: Node | null): boolean => {
				return !!child && callback(child);
			},
			this.j.editor,
			next ? 'nextSibling' : 'previousSibling',
			next ? 'firstChild' : 'lastChild'
		);
	};

	private updateCounters = () => {
		if (!this.isOpened) {
			return;
		}

		this.counterBox.style.display = this.queryInput.value.length
			? 'inline-block'
			: 'none';

		const range = this.j.s.range,
			counts: [number, number] = this.calcCounts(
				this.queryInput.value,
				range
			);

		this.counterBox.textContent = counts.join('/');
	};

	private boundAlreadyWas(
		current: ISelectionRange,
		bounds: ISelectionRange[]
	): boolean {
		return bounds.some((bound: ISelectionRange) => {
			return (
				bound.startContainer === current.startContainer &&
				bound.endContainer === current.endContainer &&
				bound.startOffset === current.startOffset &&
				bound.endOffset === current.endOffset
			);
		}, false);
	}

	private tryScrollToElement(startContainer: Node) {
		// find scrollable element
		let parentBox: HTMLElement | false = Dom.closest(
			startContainer,
			Dom.isElement,
			this.j.editor
		) as HTMLElement | false;

		if (!parentBox) {
			parentBox = Dom.prev(
				startContainer,
				Dom.isElement,
				this.j.editor
			) as HTMLElement | false;
		}

		parentBox && parentBox !== this.j.editor && parentBox.scrollIntoView();
	}

	searchBox!: HTMLDivElement;
	queryInput!: HTMLInputElement;
	replaceInput!: HTMLInputElement;
	closeButton!: HTMLButtonElement;
	nextButton!: HTMLButtonElement;
	prevButton!: HTMLButtonElement;
	replaceButton!: HTMLButtonElement;
	counterBox!: HTMLSpanElement;

	calcCounts = (
		query: string,
		current: ISelectionRange | false = false
	): [number, number] => {
		const bounds: ISelectionRange[] = [];

		let currentIndex: number = 0,
			count: number = 0,
			bound: ISelectionRange | false = false,
			start: Node | null = this.j.editor.firstChild;

		while (start && query.length) {
			bound = this.find(
				start,
				query,
				true,
				0,
				(bound as Range) || this.j.ed.createRange()
			);
			if (bound) {
				if (this.boundAlreadyWas(bound, bounds)) {
					break;
				}
				bounds.push(bound);
				start = bound.startContainer;
				count += 1;
				if (current && this.boundAlreadyWas(current, [bound])) {
					currentIndex = count;
				}
			} else {
				start = null;
			}
		}

		return [currentIndex, count];
	};

	findAndReplace = (start: Node | null, query: string): boolean => {
		const range = this.j.s.range,
			bound: ISelectionRange | false = this.find(
				start,
				query,
				true,
				0,
				range
			);

		if (bound && bound.startContainer && bound.endContainer) {
			const rng = this.j.ed.createRange();

			try {
				if (bound && bound.startContainer && bound.endContainer) {
					rng.setStart(
						bound.startContainer,
						bound.startOffset as number
					);

					rng.setEnd(bound.endContainer, bound.endOffset as number);
					rng.deleteContents();

					const textNode: Node = this.j.createInside.text(
						this.replaceInput.value
					);

					rng.insertNode(textNode);
					this.j.s.select(textNode);
					this.tryScrollToElement(textNode);
				}
			} catch {}

			return true;
		}

		return false;
	};

	/**
	 *
	 * @param start
	 * @param query
	 * @param next
	 */
	findAndSelect = (
		start: Node | null,
		query: string,
		next: boolean
	): boolean => {
		const range = this.j.s.range,
			bound: ISelectionRange | false = this.find(
				start,
				query,
				next,
				0,
				range
			);

		if (bound && bound.startContainer && bound.endContainer) {
			const rng: Range = this.j.ed.createRange();

			try {
				rng.setStart(bound.startContainer, bound.startOffset as number);
				rng.setEnd(bound.endContainer, bound.endOffset as number);
				this.j.s.selectRange(rng);
			} catch (e) {}

			this.tryScrollToElement(bound.startContainer);

			this.current = bound.startContainer;
			this.updateCounters();

			return true;
		}

		return false;
	};

	find = (
		start: Node | null,
		query: string,
		next: boolean,
		deep: number,
		range: Range
	): false | ISelectionRange => {
		if (start && query.length) {
			let sentence: string = '',
				bound: ISelectionRange = {
					startContainer: null,
					startOffset: null,
					endContainer: null,
					endOffset: null
				};

			this.eachMap(
				start,
				(elm: Node): boolean => {
					if (
						Dom.isText(elm) &&
						elm.nodeValue !== null &&
						elm.nodeValue.length
					) {
						let value: string = elm.nodeValue;

						if (!next && elm === range.startContainer) {
							value = !deep
								? value.substr(0, range.startOffset)
								: value.substr(range.endOffset);
						} else if (next && elm === range.endContainer) {
							value = !deep
								? value.substr(range.endOffset)
								: value.substr(0, range.startOffset);
						}

						const tmpSentence: string = next
							? sentence + value
							: value + sentence;

						const part:
							| boolean
							| string = search.findSomePartOfString(
							query,
							tmpSentence,
							next
						) as boolean | string;

						if (part !== false) {
							let currentPart:
								| string
								| boolean = search.findSomePartOfString(
								query,
								value,
								next
							) as string | boolean;

							if (currentPart === true) {
								currentPart = trim(query);
							} else if (currentPart === false) {
								currentPart = search.findSomePartOfString(
									value,
									query,
									next
								) as string | true;
								if (currentPart === true) {
									currentPart = trim(value);
								}
							}

							let currentPartIndex: number =
								search.getSomePartOfStringIndex(
									query,
									value,
									next
								) || 0;

							if (
								((next && !deep) || (!next && deep)) &&
								elm.nodeValue.length - value.length > 0
							) {
								currentPartIndex +=
									elm.nodeValue.length - value.length;
							}

							if (bound.startContainer === null) {
								bound.startContainer = elm;
								bound.startOffset = currentPartIndex;
							}
							if (part !== true) {
								sentence = tmpSentence;
							} else {
								bound.endContainer = elm;
								bound.endOffset = currentPartIndex;
								bound.endOffset += (currentPart as string).length;

								return true;
							}
						} else {
							sentence = '';
							bound = {
								startContainer: null,
								startOffset: null,
								endContainer: null,
								endOffset: null
							};
						}
					} else if (Dom.isBlock(elm, this.j.ew) && sentence !== '') {
						sentence = next ? sentence + ' ' : ' ' + sentence;
					}

					return false;
				},
				next
			);

			if (bound.startContainer && bound.endContainer) {
				return bound;
			}

			if (!deep) {
				this.current = next
					? (this.j.editor.firstChild as Node)
					: (this.j.editor.lastChild as Node);
				return this.find(this.current, query, next, deep + 1, range);
			}
		}

		return false;
	};

	open = (searchAndReplace: boolean = false) => {
		if (!this.isOpened) {
			this.searchBox.classList.add('jodit-search_active');
			this.isOpened = true;
		}

		this.j.e.fire('hidePopup');
		this.searchBox.classList.toggle(
			'jodit-search_replace',
			searchAndReplace
		);

		this.current = this.j.s.current();
		this.selInfo = this.j.s.save();

		const selStr: string = (this.j.s.sel || '').toString();

		if (selStr) {
			this.queryInput.value = selStr;
		}

		this.updateCounters();

		if (selStr) {
			this.queryInput.select();
		} else {
			this.queryInput.focus();
		}
	};

	close = (): void => {
		if (!this.isOpened) {
			return;
		}

		if (this.selInfo) {
			this.j.s.restore(this.selInfo);
			this.selInfo = null;
		}

		this.searchBox.classList.remove('jodit-search_active');
		this.isOpened = false;
	};

	afterInit(editor: IJodit): void {
		if (editor.o.useSearch) {
			const self: search = this;

			self.searchBox = editor.c.fromHTML(self.template) as HTMLDivElement;

			const {
				query,
				replace,
				cancel,
				next,
				prev,
				replaceBtn,
				counterBox
			} = refs(self.searchBox);

			self.queryInput = query as HTMLInputElement;

			self.replaceInput = replace as HTMLInputElement;

			self.closeButton = cancel as HTMLButtonElement;

			self.nextButton = next as HTMLButtonElement;

			self.prevButton = prev as HTMLButtonElement;

			self.replaceButton = replaceBtn as HTMLButtonElement;

			self.counterBox = counterBox as HTMLButtonElement;

			const onInit = () => {
				editor.workplace.appendChild(this.searchBox);

				editor.e
					.off(this.j.container, 'keydown.search')
					.on(
						this.j.container,
						'keydown.search',
						(e: KeyboardEvent) => {
							if (editor.getRealMode() !== MODE_WYSIWYG) {
								return;
							}

							switch (e.key) {
								case consts.KEY_ESC:
									this.close();
									break;

								case consts.KEY_F3:
									if (self.queryInput.value) {
										editor.e.fire(
											!e.shiftKey
												? 'searchNext'
												: 'searchPrevious'
										);

										e.preventDefault();
									}
									break;
							}
						}
					);
			};
			onInit();

			editor.e
				.on('changePlace', onInit)
				.on(self.closeButton, 'click', this.close)
				.on(self.queryInput, 'mousedown', () => {
					if (editor.s.isFocused()) {
						editor.s.removeMarkers();
						self.selInfo = editor.s.save();
					}
				})
				.on(self.replaceButton, 'click', (e: MouseEvent) => {
					self.findAndReplace(
						editor.s.current() || editor.editor.firstChild,
						self.queryInput.value
					);

					this.updateCounters();

					e.preventDefault();
					e.stopImmediatePropagation();
				})
				.on([self.nextButton, self.prevButton], 'click', function(
					this: HTMLButtonElement,
					e: MouseEvent
				) {
					editor.e.fire(
						self.nextButton === this
							? 'searchNext'
							: 'searchPrevious'
					);
					e.preventDefault();
					e.stopImmediatePropagation();
				})
				.on(
					this.queryInput,
					'keydown',
					this.j.async.debounce((e: KeyboardEvent) => {
						switch (e.key) {
							case consts.KEY_ENTER:
								e.preventDefault();
								e.stopImmediatePropagation();
								if (editor.e.fire('searchNext')) {
									this.close();
								}

								break;

							default:
								this.updateCounters();
								break;
						}
					}, this.j.defaultTimeout)
				)
				.on('beforeSetMode.search', () => {
					this.close();
				})
				.on('keydown.search mousedown.search', () => {
					if (this.selInfo) {
						editor.s.removeMarkers();
						this.selInfo = null;
					}
					if (this.isOpened) {
						this.current = this.j.s.current();
						this.updateCounters();
					}
				})
				.on('searchNext.search searchPrevious.search', () => {
					return self.findAndSelect(
						editor.s.current() || editor.editor.firstChild,
						self.queryInput.value,
						editor.e.current === 'searchNext'
					);
				})
				.on('search.search', (value: string, next: boolean = true) => {
					editor.execCommand('search', value, next);
				});

			editor.registerCommand('search', {
				exec: (
					command: string,
					value?: string,
					next: boolean = true
				) => {
					self.findAndSelect(
						editor.s.current() || editor.editor.firstChild,
						value || '',
						next
					);
					return false;
				}
			});
			editor.registerCommand('openSearchDialog', {
				exec: () => {
					self.open();
					return false;
				},
				hotkeys: ['ctrl+f', 'cmd+f']
			});

			editor.registerCommand('openReplaceDialog', {
				exec: () => {
					if (!editor.o.readonly) {
						self.open(true);
					}
					return false;
				},
				hotkeys: ['ctrl+h', 'cmd+h']
			});
		}
	}

	beforeDestruct(jodit: IJodit): void {
		Dom.safeRemove(this.searchBox);
		jodit.events?.off('.search');
	}
}
