/*!
 * V4Fire Client Core
 * https://github.com/V4Fire/Client
 *
 * Released under the MIT license
 * https://github.com/V4Fire/Client/blob/master/LICENSE
 */

/**
 * [[include:base/b-tree/README.md]]
 * @packageDocumentation
 */

//#if demo
import 'models/demo/nested-list';
//#endif

import symbolGenerator from 'core/symbol';

import SyncPromise from 'core/promise/sync';
import { derive } from 'core/functools/trait';

import iItems from 'traits/i-items/i-items';
import iActiveItems, { IterationKey } from 'traits/i-active-items/i-active-items';

import iData, {

	component,

	prop,
	field,
	system,
	computed,

	hook,
	wait,
	watch,

	TaskParams,
	TaskI

} from 'super/i-data/i-data';

import type { Item, Items, RenderFilter } from 'base/b-tree/interface';

export * from 'super/i-data/i-data';
export * from 'base/b-tree/interface';

export const
	$$ = symbolGenerator();

interface bTree extends Trait<typeof iActiveItems> {}

@component({
	functional: {
		functional: true
	},

	model: {
		prop: 'activeProp',
		event: 'onChange'
	}
})

@derive(iActiveItems)
class bTree extends iData implements iActiveItems {
	/** @see [[iItems.Item]] */
	readonly Item!: Item;

	/** @see [[iItems.Items]] */
	readonly Items!: Items;

	/** @see [[iActiveItems.Active]] */
	readonly Active!: iActiveItems['Active'];

	/** @see [[iItems.items]] */
	@prop(Array)
	readonly itemsProp: this['Items'] = [];

	/** @see [[iItems.item]] */
	@prop({type: [String, Function], required: false})
	readonly item?: iItems['item'];

	/** @see [[iItems.itemKey]] */
	@prop({type: [String, Function], required: false})
	readonly itemKey?: iItems['itemKey'];

	/** @see [[iItems.itemProps]] */
	@prop({type: Function, required: false})
	readonly itemProps?: iItems['itemProps'];

	/**
	 * A common filter to render items via `asyncRender`.
	 * It is used to optimize the process of rendering items.
	 *
	 * @see [[AsyncRender.iterate]]
	 * @see [[TaskFilter]]
	 */
	@prop({
		type: Function,
		required: false,
		default(ctx: bTree, item: unknown, i: number, task: TaskI): CanPromise<boolean> {
			if (ctx.level === 0 && task.i < ctx.renderChunks) {
				return true;
			}

			return ctx.async.animationFrame().then(() => true);
		}
	})

	readonly renderFilter!: RenderFilter;

	/**
	 * A filter to render nested items via `asyncRender`.
	 * It is used to optimize the process of rendering child items.
	 *
	 * @see [[AsyncRender.iterate]]
	 * @see [[TaskFilter]]
	 */
	@prop({type: Function, required: false})
	readonly nestedRenderFilter?: RenderFilter;

	/**
	 * Number of chunks to render via `asyncRender`
	 */
	@prop(Number)
	readonly renderChunks: number = 5;

	/**
	 * If true, then all nested elements are folded by default
	 */
	@prop(Boolean)
	readonly folded: boolean = true;

	/**
	 * Link to the top level component (internal parameter)
	 */
	@prop({type: Object, required: false})
	readonly top?: bTree;

	/**
	 * Component nesting level (internal parameter)
	 */
	@prop(Number)
	readonly level: number = 0;

	/** @see [[iActiveItems.activeProp]] */
	@prop({required: false})
	readonly activeProp?: iActiveItems['activeProp'];

	/** @see [[iActiveItems.multiple]] */
	@prop(Boolean)
	readonly multiple: boolean = false;

	/** @see [[iActiveItems.cancelable]] */
	@prop({type: Boolean, required: false})
	readonly cancelable?: boolean;

	/** @see [[iItems.items]] */
	@field<bTree>((o) => o.sync.link<Items>((val) => {
		if (o.dataProvider != null) {
			return <CanUndef<Items>>o.items ?? [];
		}

		return o.normalizeItems(val);
	}))

	items!: this['Items'];

	/**
	 * @see [[iActiveItems.activeStore]]
	 * @see [[iActiveItems.syncActiveStore]]
	 */
	@system<bTree>((o) => iActiveItems.linkActiveStore(o))
	activeStore!: iActiveItems['activeStore'];

	/**
	 * A map of the item indexes and their values
	 */
	@system()
	indexes!: Dictionary;

	/**
	 * A map of the item values and their indexes
	 */
	@system()
	valueIndexes!: Map<unknown, number>;

	/**
	 * A map of the item values and their descriptors
	 */
	@system()
	valueItems!: Map<unknown, this['Item']>;

	/** @inheritDoc */
	protected override readonly $refs!: {
		children?: bTree[];
	};

	/**
	 * Parameters for async render tasks
	 */
	protected get renderTaskParams(): TaskParams {
		return {
			filter: this.renderFilter.bind(this, this)
		};
	}

	/**
	 * Props for recursively inserted tree components
	 */
	protected get nestedTreeProps(): Dictionary {
		const
			{nestedRenderFilter} = this;

		const
			isRootLvl = this.level === 0,
			renderFilter = Object.isFunction(nestedRenderFilter) ? nestedRenderFilter : this.renderFilter;

		const opts = {
			level: this.level + 1,
			top: isRootLvl ? this : this.top,
			multiple: this.multiple,
			classes: this.classes,
			renderChunks: this.renderChunks,
			activeProp: this.active,
			nestedRenderFilter,
			renderFilter
		};

		if (this.$listeners.fold) {
			opts['@fold'] = this.$listeners.fold;
		}

		return opts;
	}

	/** @see [[iActiveItems.prototype.active] */
	@computed({cache: false})
	get active(): iActiveItems['active'] {
		return iActiveItems.getActive(this.top ?? this);
	}

	/** @see [[iActiveItems.prototype.activeElement] */
	get activeElement(): iActiveItems['activeElement'] {
		const
			ctx = this.top ?? this;

		return this.waitStatus('ready', () => {
			if (ctx.multiple) {
				if (!Object.isSet(this.active)) {
					return [];
				}

				return [...this.active].flatMap((val) => this.findItemElement(val) ?? []);
			}

			return this.findItemElement(this.active);
		});
	}

	/**
	 * Returns an iterator over the tree items based on the given arguments.
	 * The iterator returns pairs of elements `[Tree item, The bTree instance associated with the element]`.
	 *
	 * @param [ctx] - a context to start iteration, the top-level tree by default
	 * @param [opts] - additional options
	 */
	traverse(
		ctx: bTree = this.top ?? this,
		opts: { deep: boolean } = {deep: true}
	): IterableIterator<[this['Item'], bTree]> {
		const
			children = ctx.$refs.children ?? [],
			iter = createIter();

		return {
			[Symbol.iterator]() {
				return this;
			},

			next: iter.next.bind(iter)
		};

		function* createIter() {
			for (const item of ctx.items) {
				yield [item, ctx];
			}

			if (opts.deep) {
				for (const child of children) {
					yield* child.traverse(child);
				}
			}
		}
	}

	/**
	 * Folds the specified item.
	 * If the method is called without an element passed, all tree sibling elements will be folded.
	 *
	 * @param [value]
	 */
	@wait('ready')
	fold(value?: unknown): Promise<boolean> {
		if (arguments.length === 0) {
			const
				values: Array<Promise<boolean>> = [];

			for (const [item] of this.traverse(this, {deep: false})) {
				values.push(this.fold(item.value));
			}

			return SyncPromise.all(values)
				.then((res) => res.some((value) => value === true));
		}

		return this.toggleFold(value, true);
	}

	/**
	 * Unfolds the specified item.
	 * If method is called on nested item, all parent items will be unfolded.
	 * If the method is called without an element passed, all tree sibling elements will be unfolded.
	 *
	 * @param [value]
	 */
	@wait('ready')
	unfold(value?: unknown): Promise<boolean> {
		const
			values: Array<Promise<boolean>> = [];

		if (arguments.length === 0) {
			for (const [item] of this.traverse(this, {deep: false})) {
				if (!this.hasChildren(item)) {
					continue;
				}

				values.push(this.unfold(item.value));
			}

		} else {
			const
				ctx = this.top ?? this,
				item = this.valueItems.get(value);

			if (item != null && this.hasChildren(item)) {
				values.push(ctx.toggleFold(value, false));
			}

			let
				{parentValue} = item ?? {};

			while (parentValue != null) {
				const
					parent = this.valueItems.get(parentValue);

				if (parent != null) {
					values.push(ctx.toggleFold(parent.value, false));
					parentValue = parent.parentValue;

				} else {
					parentValue = null;
				}
			}
		}

		return SyncPromise.all(values)
			.then((res) => res.some((value) => value === true));
	}

	/**
	 * Toggles the passed item fold value
	 *
	 * @param value
	 * @param [folded] - if value is not passed the current state will be toggled
	 * @emits `fold(target: HTMLElement, item: `[[Item]]`, value: boolean)`
	 */
	@wait('ready')
	toggleFold(value: unknown, folded?: boolean): Promise<boolean> {
		const
			ctx = this.top ?? this;

		const
			oldVal = this.getFoldedMod(value) === 'true',
			newVal = folded ?? !oldVal;

		const
			el = ctx.findItemElement(value),
			item = this.valueItems.get(value);

		if (oldVal !== newVal && el != null && item != null && this.hasChildren(item)) {
			this.block?.setElMod(el, 'node', 'folded', newVal);
			ctx.emit('fold', el, item, newVal);
			return SyncPromise.resolve(true);
		}

		return SyncPromise.resolve(false);
	}

	isActive(value: unknown): boolean {
		return iActiveItems.isActive(this.top ?? this, value);
	}

	/** @see [[iActiveItems.prototype.getItemByValue]] */
	getItemByValue(value: Item['value']): CanUndef<Item> {
		return this.valueItems.get(value);
	}

	/** @see [[iActiveItems.prototype.setActive]] */
	setActive(value: this['Active'], unsetPrevious: boolean = false): boolean {
		const
			ctx = this.top ?? this;

		if (!iActiveItems.setActive(ctx, value, unsetPrevious)) {
			return false;
		}

		void ctx.unfold(value);

		const {
			$el,
			block: $b
		} = ctx;

		if ($el != null && $b != null) {
			if (!ctx.multiple || unsetPrevious) {
				const
					previousNodes = $el.querySelectorAll(`.${$b.getFullElName('node', 'active', true)}`);

				previousNodes.forEach((previousNode) => {
					if (!this.isActive(this.valueItems.get(previousNode.getAttribute('data-id')))) {
						setActive(previousNode, false);
					}
				});
			}

			SyncPromise.resolve(this.activeElement).then((activeElement) => {
				Array.concat([], activeElement).forEach((activeElement) => setActive(activeElement, true));
			}).catch(stderr);
		}

		return true;

		function setActive(el: Element, status: boolean) {
			$b!.setElMod(el, 'node', 'active', status);

			if (el.hasAttribute('aria-selected')) {
				el.setAttribute('aria-selected', String(status));
			}
		}
	}

	unsetActive(value: this['Active']): boolean {
		const
			ctx = this.top ?? this;

		if (!iActiveItems.unsetActive(ctx, value)) {
			return false;
		}

		const {
			$el,
			block: $b
		} = ctx;

		if ($el != null && $b != null) {
			const
				previousNodes = $el.querySelectorAll(`.${$b.getFullElName('node', 'active', true)}`);

			previousNodes.forEach((previousNode) => {
				if (!this.isActive(this.valueItems.get(previousNode.getAttribute('data-id')))) {
					$b.setElMod(previousNode, 'node', 'active', false);

					if (previousNode.hasAttribute('aria-selected')) {
						previousNode.setAttribute('aria-selected', 'false');
					}
				}
			});
		}

		return true;
	}

	/** @see [[iActiveItems.prototype.toggleActive]] */
	toggleActive(value: this['Active'], unsetPrevious?: boolean): this['Active'] {
		return iActiveItems.toggleActive(this.top ?? this, value, unsetPrevious);
	}

	/** @see [[iItems.getItemKey]] */
	protected getItemKey(item: this['Item'], i: number): CanUndef<IterationKey> {
		return iItems.getItemKey(this, item, i);
	}

	protected override initRemoteData(): CanUndef<this['items']> {
		if (!this.db) {
			return;
		}

		const
			val = this.convertDBToComponent<this['items']>(this.db);

		if (Object.isArray(val)) {
			return this.items = this.normalizeItems(val);
		}

		return this.items;
	}

	/**
	 * True, if specified item has children
	 * @param item
	 */
	protected hasChildren(item: this['Item']): boolean {
		return Object.size(item.children?.length) > 0;
	}

	/**
	 * Returns a dictionary with props for the specified item
	 *
	 * @param item
	 * @param i - position index
	 */
	protected getItemProps(item: this['Item'], i: number): Dictionary {
		const
			op = this.itemProps,
			props = Object.reject(item, ['value', 'parentValue', 'children', 'folded']);

		if (op == null) {
			return props;
		}

		return Object.isFunction(op) ?
			op(item, i, {
				key: this.getItemKey(item, i),
				ctx: this,
				...props
			}) :

			Object.assign(props, op);
	}

	/**
	 * Returns a dictionary with props for the specified item
	 * @param item
	 */
	protected getFoldProps(item: this['Item']): Dictionary {
		return {
			'@click': this.onFoldClick.bind(this, item)
		};
	}

	/**
	 * Returns a value of the `folded` property from the specified item
	 * @param item
	 */
	protected getFoldedPropValue(item: this['Item']): boolean {
		if (item.folded != null) {
			return item.folded;
		}

		return this.top?.folded ?? this.folded;
	}

	/**
	 * Returns a value of the `folded` modifier from an element by the specified identifier
	 * @param value
	 */
	protected getFoldedMod(value: unknown): CanUndef<string> {
		const
			target = this.findItemElement(value);

		if (target == null) {
			return;
		}

		return this.block?.getElMod(target, 'node', 'folded');
	}

	/**
	 * Searches an HTML element by the specified item value and returns it
	 * @param value
	 */
	protected findItemElement(value: unknown): HTMLElement | null {
		const
			ctx = this.top ?? this,
			id = this.valueIndexes.get(value);

		if (id == null) {
			return null;
		}

		return ctx.$el?.querySelector(`[data-id="${id}"]`) ?? null;
	}

	/**
	 * Synchronization of items
	 *
	 * @param items
	 * @param oldItems
	 * @emits `itemsChange(value: this['Items'])`
	 */
	@watch({path: 'items', immediate: true})
	protected syncItemsWatcher(items: this['Items'], oldItems?: this['Items']): void {
		if (!Object.fastCompare(items, oldItems)) {
			this.initComponentValues(oldItems != null);
			this.async.setImmediate(() => this.emit('itemsChange', items), {label: $$.syncItemsWatcher});
		}
	}

	/**
	 * Initializes component values
	 * @param [itemsChanged] - true, if the method is invoked after items changed
	 */
	@hook('beforeDataCreate')
	protected initComponentValues(itemsChanged: boolean = false): void {
		const
			that = this,
			{active} = this;

		let
			hasActive = false,
			activeItem;

		if (this.top == null) {
			this.indexes = {};
			this.valueIndexes = new Map();
			this.valueItems = new Map();

			traverse(this.field.get<this['Items']>('items'));

			// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
			if (!hasActive) {
				if (itemsChanged && active != null) {
					this.field.set('activeStore', undefined);
				}

				if (activeItem != null) {
					iActiveItems.initItem(this, activeItem);
				}
			}

		} else {
			Object.defineProperty(this, 'indexes', {
				enumerable: true,
				configurable: true,
				get: () => this.top?.indexes
			});

			Object.defineProperty(this, 'valueIndexes', {
				enumerable: true,
				configurable: true,
				get: () => this.top?.valueIndexes
			});

			Object.defineProperty(this, 'valueItems', {
				enumerable: true,
				configurable: true,
				get: () => this.top?.valueItems
			});
		}

		function traverse(items?: Items) {
			items?.forEach((item) => {
				const
					{value} = item;

				if (that.valueIndexes.has(value)) {
					return;
				}

				const
					id = that.valueIndexes.size;

				that.indexes[id] = value;
				that.valueIndexes.set(value, id);
				that.valueItems.set(value, item);

				if (item.value === active) {
					hasActive = true;
				}

				if (item.active) {
					activeItem = item;
				}

				if (Object.isArray(item.children)) {
					traverse(item.children);
				}
			});
		}
	}

	/**
	 * Normalizes the specified items and returns it
	 * @param [items]
	 */
	protected normalizeItems(items: this['Items'] = []): this['Items'] {
		const
			that = this;

		let
			i = -1;

		items = Object.fastClone(items);
		items.forEach((el) => normalize(el));

		return items;

		function normalize(item: bTree['Item'], parentValue?: unknown) {
			i++;

			if (item.value === undefined) {
				item.value = i;
			}

			if (!('parentValue' in item)) {
				item.parentValue = parentValue;

				if (Object.isArray(item.children)) {
					if (that.isActive(item.value)) {
						item.folded = false;
					}

					for (const el of item.children) {
						if (normalize(el, item.value)) {
							item.folded = false;
							break;
						}
					}
				}
			}

			return that.isActive(item.value) || item.folded === false;
		}
	}

	/**
	 * Handler: fold element has been clicked
	 * @param item
	 */
	protected onFoldClick(item: this['Item']): void {
		void this.toggleFold(item.value);
	}

	/**
	 * Handler: click to some item element
	 *
	 * @param e
	 * @emits `actionChange(active: this['Active'])`
	 */
	@watch<bTree>({
		path: '?$el:click',
		wrapper: (o, cb) => o.dom.delegateElement('node', cb)
	})

	protected onItemClick(e: Event): void {
		e.stopPropagation();

		let
			target = <Element>e.target;

		if (target.matches(this.block!.getElSelector('fold'))) {
			return;
		}

		target = <Element>e.delegateTarget;

		const
			id = target.getAttribute('data-id');

		if (id != null) {
			this.toggleActive(this.indexes[id]);
		}

		(this.top ?? this).emit('actionChange', this.active);
	}
}

export default bTree;
