/*!
 * 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 autobind from 'autobind-decorator';

import { CanUndef, IJodit, markerInfo, Nullable } from '../../../types';
import { isPlainObject, isVoid } from '../../helpers/checker';
import { Dom } from '../../dom';
import {
	attr,
	css,
	each,
	normalizeCssValue,
	normalizeNode,
	trim
} from '../../helpers';
import { IStyle, Style } from './style';

enum mode {
	UNWRAP = 'UNWRAP',
	WRAP = 'WRAP'
}

/**
 * @see [[Select.prototype.applyStyle]]
 */
export class ApplyStyle {
	constructor(readonly jodit: IJodit, readonly style: Style) {}

	/**
	 * Apply options to selection
	 */
	apply(): void {
		const sel = this.jodit.selection;

		let selInfo: markerInfo[] = [];

		const isCollapsed = sel.isCollapsed();

		if (isCollapsed) {
			const font = this.jodit.createInside.element('font');
			sel.insertNode(font, false, false);
			sel.setCursorIn(font);
			selInfo = sel.save();
			this.applyToElement(font);
			Dom.unwrap(font);
		} else {
			selInfo = sel.save();
			normalizeNode(sel.area.firstChild); // FF fix for test "commandsTest - Exec command "bold"

			// for some text that contains a few STRONG elements, should unwrap all of these"
			sel.wrapInTag(this.applyToElement);
		}

		sel.restore(selInfo);
	}

	/**
	 * Mode WRAP or UNWRAP
	 */
	private mode: CanUndef<keyof typeof mode>;

	/**
	 * Apply options to all selected fragment
	 * @param font
	 */
	@autobind
	private applyToElement(font: HTMLElement): void {
		const { area } = this.jodit.selection;

		if (
			this.checkSuitableParent(font) ||
			this.checkSuitableChild(font) ||
			this.checkClosestWrapper(font) ||
			this.unwrapChildren(font)
		) {
			return;
		}

		if (!this.mode) {
			this.mode = mode.WRAP;
		}

		if (this.mode !== mode.WRAP) {
			return;
		}

		let wrapper = font;

		if (this.style.elementIsBlock) {
			const ulReg = /^(ul|ol|li|td|th|tr|tbody|table)$/i;

			const box = Dom.up(
				font,
				node => {
					if (node && Dom.isBlock(node, this.jodit.s.win)) {
						if (
							ulReg.test(this.style.element) ||
							!ulReg.test(node.nodeName)
						) {
							return true;
						}
					}

					return false;
				},
				area
			);

			if (box) {
				wrapper = box;
			} else {
				wrapper = this.wrapUnwrappedText(font);
			}
		}

		const newWrapper = Dom.replace(
			wrapper,
			this.style.element,
			this.jodit.createInside
		);

		if (this.style.elementIsBlock) {
			this.postProcessListElement(newWrapper);
		}

		if (this.style.options.style && this.style.elementIsDefault) {
			css(newWrapper, this.style.options.style);
		}
	}

	private checkSuitableParent(font: HTMLElement): boolean {
		const { parentNode } = font;

		if (
			parentNode &&
			!Dom.next(font, this.isNormalNode, parentNode) &&
			!Dom.prev(font, this.isNormalNode, parentNode) &&
			this.isSuitableElement(parentNode, false) &&
			parentNode !== this.jodit.s.area &&
			(!Dom.isBlock(parentNode, this.jodit.ew) ||
				this.style.elementIsBlock)
		) {
			this.toggleStyles(parentNode);
			return true;
		}

		return false;
	}

	/**
	 * Check suitable first child
	 *
	 * @param font
	 * @example
	 * `<font><strong>selected</strong></font>`
	 */
	private checkSuitableChild(font: HTMLElement): boolean {
		let { firstChild } = font;

		if (firstChild && this.jodit.s.isMarker(firstChild as HTMLElement)) {
			firstChild = firstChild.nextSibling;
		}

		if (
			firstChild &&
			!Dom.next(firstChild, this.isNormalNode, font) &&
			!Dom.prev(firstChild, this.isNormalNode, font) &&
			this.isSuitableElement(firstChild, false)
		) {
			this.toggleStyles(firstChild);
			return true;
		}

		return false;
	}

	/**
	 * Check closest suitable wrapper element
	 *
	 * @param font
	 * @example
	 * `<strong><span>zxc<font>selected</font>dfdsf</span></strong>`
	 */
	private checkClosestWrapper(font: HTMLElement): boolean {
		const wrapper = Dom.closest(
			font,
			this.isSuitableElement,
			this.jodit.editor
		);

		if (wrapper) {
			if (this.style.elementIsBlock) {
				this.toggleStyles(wrapper);
				return true;
			}

			const leftRange = this.jodit.s.createRange();

			leftRange.setStartBefore(wrapper);
			leftRange.setEndBefore(font);

			const leftFragment = leftRange.extractContents();

			if (
				(!leftFragment.textContent ||
					!trim(leftFragment.textContent).length) &&
				leftFragment.firstChild
			) {
				Dom.unwrap(leftFragment.firstChild);
			}

			if (wrapper.parentNode) {
				wrapper.parentNode.insertBefore(leftFragment, wrapper);
			}

			leftRange.setStartAfter(font);
			leftRange.setEndAfter(wrapper);

			const rightFragment = leftRange.extractContents();

			// case then marker can be inside fragnment
			if (
				(!rightFragment.textContent ||
					!trim(rightFragment.textContent).length) &&
				rightFragment.firstChild
			) {
				Dom.unwrap(rightFragment.firstChild);
			}

			Dom.after(wrapper, rightFragment);

			this.toggleStyles(wrapper);

			return true;
		}

		return false;
	}

	/**
	 * Element has all rules
	 * @param elm
	 * @param rules
	 */
	private elementHasSameStyle(elm: Node, rules: CanUndef<IStyle>): boolean {
		return Boolean(
			isPlainObject(rules) &&
				!Dom.isTag(elm, 'font') &&
				Dom.isHTMLElement(elm, this.jodit.ew) &&
				each(rules, (property, checkValue) => {
					const value = css(elm, property, undefined, true);

					return (
						!isVoid(value) &&
						value !== '' &&
						!isVoid(checkValue) &&
						normalizeCssValue(property, checkValue)
							.toString()
							.toLowerCase() === value.toString().toLowerCase()
					);
				})
		);
	}

	/**
	 * This element is suitable for options
	 *
	 * @param elm
	 * @param strict
	 */
	@autobind
	isSuitableElement(
		elm: Nullable<Node>,
		strict: boolean = true
	): elm is HTMLElement {
		if (!elm) {
			return false;
		}

		const { element, elementIsDefault, options } = this.style;

		const elmHasSameStyle = this.elementHasSameStyle(elm, options.style);
		const elmIsSame = elm.nodeName.toLowerCase() === element;

		return (
			((!elementIsDefault || !strict) && elmIsSame) ||
			(elmHasSameStyle && this.isNormalNode(elm))
		);
	}

	/**
	 * Is normal usual element
	 * @param elm
	 */
	@autobind
	private isNormalNode(elm: Nullable<Node>): boolean {
		return Boolean(
			elm !== null &&
				!Dom.isEmptyTextNode(elm) &&
				!this.jodit.s.isMarker(elm as HTMLElement)
		);
	}

	/**
	 * Add or remove styles to element
	 * @param elm
	 */
	private toggleStyles(elm: HTMLElement): void {
		const { style } = this.style.options;

		// toggle CSS rules
		if (style && elm.nodeName.toLowerCase() === this.style.defaultTag) {
			Object.keys(style).forEach(rule => {
				if (
					this.mode === mode.UNWRAP ||
					css(elm, rule) ===
						normalizeCssValue(rule, style[rule] as string)
				) {
					css(elm, rule, '');

					if (this.mode === undefined) {
						this.mode = mode.UNWRAP;
					}
				} else {
					css(elm, rule, style[rule]);

					if (this.mode === undefined) {
						this.mode = mode.WRAP;
					}
				}
			});
		}

		const isBlock = Dom.isBlock(elm, this.jodit.ew);

		const isSuitableInline =
			!isBlock &&
			(!attr(elm, 'style') ||
				elm.nodeName.toLowerCase() !== this.style.defaultTag);

		const isSuitableBlock =
			!isSuitableInline &&
			isBlock &&
			elm.nodeName.toLowerCase() === this.style.element;

		if (isSuitableInline || isSuitableBlock) {
			// toggle `<strong>test</strong>` toWYSIWYG `test`, and
			// `<span style="">test</span>` toWYSIWYG `test`
			Dom.unwrap(elm);

			if (this.mode === undefined) {
				this.mode = mode.UNWRAP;
			}
		}
	}

	/**
	 * Unwrap all suit elements inside
	 * @param font
	 */
	private unwrapChildren(font: HTMLElement): boolean {
		const needUnwrap: Node[] = [];
		let firstElementSuit: boolean | undefined;

		if (font.firstChild) {
			Dom.find(
				font.firstChild,
				(elm: Node | null) => {
					if (elm && this.isSuitableElement(elm as HTMLElement)) {
						if (firstElementSuit === undefined) {
							firstElementSuit = true;
						}

						needUnwrap.push(elm);
					} else {
						if (firstElementSuit === undefined) {
							firstElementSuit = false;
						}
					}

					return false;
				},
				font,
				true
			);
		}

		needUnwrap.forEach(Dom.unwrap);

		return Boolean(firstElementSuit);
	}

	/**
	 * Wrap text or inline elements inside Block element
	 * @param elm
	 */
	private wrapUnwrappedText(elm: Node): HTMLElement {
		const { area, win } = this.jodit.selection;

		const edge = (n: Node, key: keyof Node = 'previousSibling') => {
			let edgeNode: Node = n,
				node: Nullable<Node> = n;

			while (node) {
				edgeNode = node;

				if (node[key]) {
					node = node[key] as Nullable<Node>;
				} else {
					node =
						node.parentNode &&
						!Dom.isBlock(node.parentNode, win) &&
						node.parentNode !== area
							? node.parentNode
							: null;
				}

				if (Dom.isBlock(node, win)) {
					break;
				}
			}

			return edgeNode;
		};

		const start: Node = edge(elm),
			end: Node = edge(elm, 'nextSibling');

		const range = this.jodit.s.createRange();
		range.setStartBefore(start);
		range.setEndAfter(end);
		const fragment = range.extractContents();

		const wrapper = this.jodit.createInside.element(this.style.element);
		wrapper.appendChild(fragment);
		range.insertNode(wrapper);

		if (this.style.elementIsBlock) {
			this.postProcessListElement(wrapper);

			if (
				Dom.isEmpty(wrapper) &&
				!Dom.isTag(wrapper.firstElementChild, 'br')
			) {
				wrapper.appendChild(this.jodit.createInside.element('br'));
			}
		}

		return wrapper;
	}

	/**
	 * Post process UL or OL element
	 * @param wrapper
	 */
	private postProcessListElement(wrapper: HTMLElement): void {
		// Add extra LI inside UL/OL
		if (
			/^(OL|UL)$/i.test(this.style.element) &&
			!Dom.isTag(wrapper.firstElementChild, 'li')
		) {
			const li = Dom.replace(wrapper, 'li', this.jodit.createInside);
			const ul = Dom.wrap(li, this.style.element, this.jodit);

			if (ul) {
				wrapper = ul;
			}
		}
	}
}
