import ClassMethodBinder from '../utilities/ClassMethodBinder.js';
import type Element from '../nodes/element/Element.js';
import * as PropertySymbol from '../PropertySymbol.js';

const ATTRIBUTE_SPLIT_REGEXP = /[\t\f\n\r ]+/;

/**
 * DOM Token List.
 *
 * Reference:
 * https://developer.mozilla.org/en-US/docs/Web/API/DOMTokenList.
 */
export default class DOMTokenList {
	[index: number]: string;

	private [PropertySymbol.ownerElement]: Element;
	private [PropertySymbol.attributeName]: string;
	private [PropertySymbol.cache]: { items: string[]; attributeValue: string } = {
		items: [],
		attributeValue: ''
	};
	private [PropertySymbol.supports]: string[];
	private [PropertySymbol.proxy]?: this;

	/**
	 * Constructor.
	 *
	 * @param illegalConstructorSymbol Illegal constructor symbol.
	 * @param ownerElement Owner element.
	 * @param attributeName Attribute name.
	 * @param [supports] Supports.
	 */
	constructor(
		illegalConstructorSymbol: symbol,
		ownerElement: Element,
		attributeName: string,
		supports?: string[]
	) {
		if (illegalConstructorSymbol !== PropertySymbol.illegalConstructor) {
			throw new TypeError('Illegal constructor');
		}

		this[PropertySymbol.ownerElement] = ownerElement;
		this[PropertySymbol.attributeName] = attributeName;
		this[PropertySymbol.supports] = supports || [];

		const methodBinder = new ClassMethodBinder(this, [DOMTokenList]);

		const proxy = new Proxy(this, {
			get: (target, property) => {
				if (property === 'length') {
					return target[PropertySymbol.getTokenList]().length;
				}
				if (property in target || typeof property === 'symbol') {
					methodBinder.bind(property);
					return (<any>target)[property];
				}
				const index = Number(property);
				if (!isNaN(index)) {
					return target[PropertySymbol.getTokenList]()[index];
				}
			},
			set(target, property, newValue): boolean {
				methodBinder.bind(property);
				if (typeof property === 'symbol') {
					(<any>target)[property] = newValue;
					return true;
				}
				const index = Number(property);
				if (isNaN(index)) {
					(<any>target)[property] = newValue;
				}
				return true;
			},
			deleteProperty(target, property): boolean {
				if (typeof property === 'symbol') {
					delete (<any>target)[property];
					return true;
				}
				const index = Number(property);
				if (isNaN(index)) {
					delete (<any>target)[property];
				}
				return true;
			},
			ownKeys(target): string[] {
				return Object.keys(target[PropertySymbol.getTokenList]());
			},
			has(target, property): boolean {
				if (property in target) {
					return true;
				}

				if (typeof property === 'symbol') {
					return false;
				}

				const index = Number(property);
				return !isNaN(index) && index >= 0 && index < target[PropertySymbol.getTokenList]().length;
			},
			defineProperty(target, property, descriptor): boolean {
				methodBinder.preventBinding(property);

				if (property in target) {
					Object.defineProperty(target, property, descriptor);
					return true;
				}

				return false;
			},
			getOwnPropertyDescriptor(target, property): PropertyDescriptor | undefined {
				if (property in target || typeof property === 'symbol') {
					return;
				}

				const index = Number(property);
				const items = target[PropertySymbol.getTokenList]();

				if (!isNaN(index) && items[index]) {
					return {
						value: items[index],
						writable: false,
						enumerable: true,
						configurable: true
					};
				}
			}
		});
		this[PropertySymbol.proxy] = proxy;

		return proxy;
	}

	/**
	 * Returns length.
	 *
	 * @returns Length.
	 */
	public get length(): number {
		return this[PropertySymbol.getTokenList]().length;
	}

	/**
	 * Set value.
	 *
	 * @param value Value.
	 */
	public set value(value: string) {
		this[PropertySymbol.ownerElement].setAttribute(this[PropertySymbol.attributeName], value);
	}

	/**
	 * Get value.
	 */
	public get value(): string {
		return this[PropertySymbol.ownerElement].getAttribute(this[PropertySymbol.attributeName]) || '';
	}

	/**
	 * Returns an iterator, allowing you to go through all values of the key/value pairs contained in this object.
	 */
	public [Symbol.iterator](): ArrayIterator<string> {
		return this[PropertySymbol.getTokenList]().values();
	}

	/**
	 * Get ClassName.
	 *
	 * @param index Index.
	 * */
	public item(index: number | string): string | null {
		const items = this[PropertySymbol.getTokenList]();
		if (typeof index === 'number') {
			return items[index] ? items[index] : null;
		}
		index = Number(index);
		index = isNaN(index) ? 0 : index;
		return items[index] ? items[index] : null;
	}

	/**
	 * Replace Token.
	 *
	 * @param token Token.
	 * @param newToken NewToken.
	 */
	public replace(token: string, newToken: string): boolean {
		const list = this[PropertySymbol.getTokenList]().slice();
		const index = list.indexOf(token);
		if (index === -1) {
			return false;
		}
		list[index] = newToken;
		this[PropertySymbol.ownerElement].setAttribute(
			this[PropertySymbol.attributeName],
			list.join(' ')
		);
		return true;
	}

	/**
	 * Supports.
	 *
	 * @param token Token.
	 */
	public supports(token: string): boolean {
		return this[PropertySymbol.supports].includes(token);
	}

	/**
	 * Returns an iterator, allowing you to go through all values of the key/value pairs contained in this object.
	 */
	public values(): ArrayIterator<string> {
		return this[PropertySymbol.getTokenList]().values();
	}

	/**
	 * Returns an iterator, allowing you to go through all key/value pairs contained in this object.
	 */
	public entries(): ArrayIterator<[number, string]> {
		return this[PropertySymbol.getTokenList]().entries();
	}

	/**
	 * Executes a provided callback function once for each DOMTokenList element.
	 *
	 * @param callback
	 * @param thisArg
	 */
	public forEach(
		callback: (currentValue: string, currentIndex: number, parent: this) => void,
		thisArg?: any
	): void {
		const thisArgValue = thisArg ?? this[PropertySymbol.ownerElement][PropertySymbol.window];
		const items = this[PropertySymbol.getTokenList]();
		const proxy = this[PropertySymbol.proxy] ?? this;
		for (let i = 0, max = items.length; i < max; i++) {
			callback.call(thisArgValue, items[i], i, proxy);
		}
	}

	/**
	 * Returns an iterator, allowing you to go through all keys of the key/value pairs contained in this object.
	 *
	 */
	public keys(): ArrayIterator<number> {
		return this[PropertySymbol.getTokenList]().keys();
	}

	/**
	 * Adds tokens.
	 *
	 * @param tokens Tokens.
	 */
	public add(...tokens: string[]): void {
		const list = this[PropertySymbol.getTokenList]().slice();
		const existingTokens = new Set(list);

		for (const token of tokens) {
			if (!existingTokens.has(token)) {
				existingTokens.add(token);
				list.push(token);
			}
		}

		this[PropertySymbol.ownerElement].setAttribute(
			this[PropertySymbol.attributeName],
			list.join(' ')
		);
	}

	/**
	 * Removes tokens.
	 *
	 * @param tokens Tokens.
	 */
	public remove(...tokens: string[]): void {
		const list = this[PropertySymbol.getTokenList]();
		const tokensToRemove = new Set(tokens);
		const newList = list.filter((item) => !tokensToRemove.has(item));

		this[PropertySymbol.ownerElement].setAttribute(
			this[PropertySymbol.attributeName],
			newList.join(' ')
		);
	}

	/**
	 * Check if the list contains a class.
	 *
	 * @param className Class name.
	 * @returns TRUE if it contains.
	 */
	public contains(className: string): boolean {
		return this[PropertySymbol.getTokenList]().includes(className);
	}

	/**
	 * Toggle a class name.
	 *
	 * @param token A string representing the class name you want to toggle.
	 * @param [force] If included, turns the toggle into a one way-only operation. If set to `false`, then class name will only be removed, but not added. If set to `true`, then class name will only be added, but not removed.
	 * @returns A boolean value, `true` or `false`, indicating whether class name is in the list after the call or not.
	 */
	public toggle(token: string, force?: boolean): boolean {
		let shouldAdd: boolean;

		if (force !== undefined) {
			shouldAdd = force;
		} else {
			shouldAdd = !this.contains(token);
		}

		if (shouldAdd) {
			this.add(token);
			return true;
		}

		this.remove(token);

		return false;
	}

	/**
	 * Returns token list from attribute value.
	 *
	 * @see https://infra.spec.whatwg.org/#split-on-ascii-whitespace
	 */
	public [PropertySymbol.getTokenList](): string[] {
		const attributeValue =
			this[PropertySymbol.ownerElement].getAttribute(this[PropertySymbol.attributeName]) ?? '';

		const cache = this[PropertySymbol.cache];

		if (cache.attributeValue === attributeValue) {
			return cache.items;
		}

		// It is possible to make this statement shorter by using Array.from() and Set, but this is faster when comparing using a bench test.
		const items: string[] = [];
		const trimmed = attributeValue.trim();

		if (trimmed) {
			for (const item of trimmed.split(ATTRIBUTE_SPLIT_REGEXP)) {
				if (!items.includes(item)) {
					items.push(item);
				}
			}
		}

		cache.attributeValue = attributeValue;
		cache.items = items;

		return items;
	}

	/**
	 * Returns DOMTokenList value.
	 */
	public toString(): string {
		return this.value || '';
	}
}
