/*!
 * Jodit Editor (https://xdsoft.net/jodit/)
 * License GNU General License version 2 or later;
 * Copyright 2013-2019 Valeriy Chupurnov https://xdsoft.net
 */

/**
 * The module editor's event manager
 */

import { CallbackFunction, EventHandlerBlock } from '../../types';
import { defaultNameSpace, EventHandlersStore } from './store';
import { IEventsNative } from '../../types/events';

export class EventsNative implements IEventsNative {
    private __key: string = '__JoditEventsNativeNamespaces';

    private doc: Document = document;

    private __stopped: EventHandlerBlock[][] = [];

    private eachEvent(
        events: string,
        callback: (event: string, namespace: string) => void
    ) {
        const eventParts: string[] = events.split(/[\s,]+/);

        eventParts.forEach((eventNameSpace: string) => {
            const eventAndNameSpace: string[] = eventNameSpace.split('.');

            const namespace: string = eventAndNameSpace[1] || defaultNameSpace;

            callback.call(this, eventAndNameSpace[0], namespace);
        });
    }

    private getStore(subject: any): EventHandlersStore {
        if (subject[this.__key] === undefined) {
            const store: EventHandlersStore = new EventHandlersStore();

            Object.defineProperty(subject, this.__key, {
                enumerable: false,
                configurable: true,
                value: store,
            });
        }

        return subject[this.__key];
    }
    private clearStore(subject: any) {
        if (subject[this.__key] !== undefined) {
            delete subject[this.__key];
        }
    }

    private prepareEvent = (
        event: TouchEvent | MouseEvent | ClipboardEvent
    ) => {
        if (event.cancelBubble) {
            return;
        }

        if (
            event.type.match(/^touch/) &&
            (event as TouchEvent).changedTouches &&
            (event as TouchEvent).changedTouches.length
        ) {
            ['clientX', 'clientY', 'pageX', 'pageY'].forEach((key: string) => {
                Object.defineProperty(event, key, {
                    value: ((event as TouchEvent).changedTouches[0] as any)[
                        key
                    ],
                    configurable: true,
                    enumerable: true,
                });
            });
        }

        if (!(event as any).originalEvent) {
            (event as any).originalEvent = event;
        }

        if (
            event.type === 'paste' &&
            (event as ClipboardEvent).clipboardData === undefined &&
            (this.doc.defaultView as any).clipboardData
        ) {
            Object.defineProperty(event, 'clipboardData', {
                get: () => {
                    return (this.doc.defaultView as any).clipboardData;
                },
                configurable: true,
                enumerable: true,
            });
        }
    };

    private triggerNativeEvent(
        element: Document | Element | HTMLElement | Window,
        event: string | Event | MouseEvent
    ) {
        const evt: Event = this.doc.createEvent('HTMLEvents');

        if (typeof event === 'string') {
            evt.initEvent(event, true, true);
        } else {
            evt.initEvent(event.type, event.bubbles, event.cancelable);

            [
                'screenX',
                'screenY',
                'clientX',
                'clientY',
                'target',
                'srcElement',
                'currentTarget',
                'timeStamp',
                'which',
                'keyCode',
            ].forEach(property => {
                Object.defineProperty(evt, property, {
                    value: (event as any)[property],
                    enumerable: true,
                });
            });

            Object.defineProperty(evt, 'originalEvent', {
                value: event,
                enumerable: true,
            });
        }

        element.dispatchEvent(evt);
    }

    private removeStop(__currentBlocks: EventHandlerBlock[]) {
        if (__currentBlocks) {
            const index: number = this.__stopped.indexOf(__currentBlocks);
            index !== -1 && this.__stopped.splice(index, 1);
        }
    }
    private isStopped(__currentBlocks: EventHandlerBlock[]): boolean {
        return (
            __currentBlocks !== undefined &&
            this.__stopped.indexOf(__currentBlocks) !== -1
        );
    }

    /**
     * Get current event name
     *
     * @example
     * ```javascript
     * parent.events.on('openDialog closeDialog', function () {
     *     if (parent.events.current === 'closeDialog') {
     *         alert('Dialog was closed');
     *     } else {
     *         alert('Dialog was opened');
     *     }
     * });
     * ```
     */
    current: string[] = [];

    /**
     * Sets the handler for the specified event ( Event List ) for a given element .
     *
     * @param {object|string} subjectOrEvents - The object for which toWYSIWYG set an event handler
     * @param {string|Function} eventsOrCallback - List of events , separated by a space or comma
     * @param {function} [handlerOrSelector] - The event handler
     * @param {selector} [selector] - Selector for capturing
     * @param {Boolean} [onTop=false] - Set handler in first
     *
     * @example
     * ```javascript
     * // set global handler
     * parent.on('beforeCommand', function (command) {
     *     alert('command');
     * });
     * ```
     * * @example
     * ```javascript
     * // set global handler
     * parent.on(document.body, 'click', function (e) {
     *     alert(this.href);
     * }, 'a');
     * ```
     */
    on(
        subjectOrEvents: string,
        eventsOrCallback: CallbackFunction,
        handlerOrSelector?: void,
        selector?: string,
        onTop?: boolean
    ): EventsNative;

    on(
        subjectOrEvents: object,
        eventsOrCallback: string,
        handlerOrSelector: CallbackFunction,
        selector?: string,
        onTop?: boolean
    ): EventsNative;

    on(
        subjectOrEvents: object | string,
        eventsOrCallback: string | CallbackFunction,
        handlerOrSelector?: CallbackFunction | void,
        selector?: string,
        onTop: boolean = false
    ): EventsNative {
        const subject: object =
            typeof subjectOrEvents === 'string' ? this : subjectOrEvents;

        const events: string =
            typeof eventsOrCallback === 'string'
                ? eventsOrCallback
                : (subjectOrEvents as string);

        let callback = handlerOrSelector as CallbackFunction;

        if (callback === undefined && typeof eventsOrCallback === 'function') {
            callback = eventsOrCallback as CallbackFunction;
        }

        const store: EventHandlersStore = this.getStore(subject);

        if (typeof events !== 'string' || events === '') {
            throw new Error('Need events names');
        }

        if (typeof callback !== 'function') {
            throw new Error('Need event handler');
        }

        if (Array.isArray(subject)) {
            subject.forEach((subj: object) => {
                this.on(subj, events, callback, selector);
            });

            return this;
        }

        const isDOMElement: boolean =
                typeof (subject as any).addEventListener === 'function',
            self: EventsNative = this;

        let syntheticCallback = function(
            this: any,
            event: MouseEvent | TouchEvent
        ) {
            return callback && callback.apply(this, arguments as any);
        };

        if (isDOMElement) {
            syntheticCallback = function(
                this: any,
                event: MouseEvent | TouchEvent
            ) {
                self.prepareEvent(event as TouchEvent);

                if (callback && callback.call(this, event) === false) {
                    event.preventDefault();
                    event.stopImmediatePropagation();
                    return false;
                }

                return;
            };

            if (selector) {
                syntheticCallback = function(
                    this: any,
                    event: TouchEvent | MouseEvent
                ): false | void {
                    self.prepareEvent(event);
                    let node: Element | null = event.target as any;
                    while (node && node !== this) {
                        if (node.matches(selector as string)) {
                            Object.defineProperty(event, 'target', {
                                value: node,
                                configurable: true,
                                enumerable: true,
                            });

                            if (
                                callback &&
                                callback.call(node, event) === false
                            ) {
                                event.preventDefault();
                                return false;
                            }

                            return;
                        }
                        node = node.parentNode as Element | null;
                    }
                };
            }
        }

        this.eachEvent(
            events,
            (event: string, namespace: string): void => {
                if (event === '') {
                    throw new Error('Need event name');
                }

                if (store.indexOf(event, namespace, callback) === false) {
                    const block: EventHandlerBlock = {
                        event,
                        originalCallback: callback,
                        syntheticCallback,
                    };

                    store.set(event, namespace, block, onTop);

                    if (isDOMElement) {
                        (subject as HTMLElement).addEventListener(
                            event,
                            syntheticCallback as EventListener,
                            false
                        );
                    }
                }
            }
        );

        return this;
    }

    /**
     * Disable all handlers specified event ( Event List ) for a given element. Either a specific event handler.
     *
     * @param {object} subjectOrEvents - The object which is disabled handlers
     * @param {string|Function} [eventsOrCallback] - List of events, separated by a space or comma , which is necessary
     * toWYSIWYG disable the handlers for a given object
     * @param {function} [handler] - Specific event handler toWYSIWYG be removed
     *
     * @example
     * ```javascript
     * var a = {name: "Anton"};
     * parent.events.on(a, 'open', function () {
     *     alert(this.name);
     * });
     *
     * parent.events.fire(a, 'open');
     * parent.events.off(a, 'open');
     * var b = {name: "Ivan"}, hndlr = function () {
     *  alert(this.name);
     * };
     * parent.events.on(b, 'open close', hndlr);
     * parent.events.fire(a, 'open');
     * parent.events.off(a, 'open', hndlr);
     * parent.events.fire(a, 'close');
     * parent.events.on('someGlobalEvents', function () {
     *   console.log(this); // parent
     * });
     * parent.events.fire('someGlobalEvents');
     * parent.events.off('someGlobalEvents');
     * ```
     */
    off(subjectOrEvents: string, eventsOrCallback?: () => void): EventsNative;
    off(
        subjectOrEvents: object,
        eventsOrCallback?: string,
        handler?: () => void
    ): EventsNative;
    off(
        subjectOrEvents: object | string,
        eventsOrCallback?: string | (() => void),
        handler?: () => void
    ): EventsNative {
        const subject: object =
            typeof subjectOrEvents === 'string' ? this : subjectOrEvents;
        const events: string =
            typeof eventsOrCallback === 'string'
                ? eventsOrCallback
                : (subjectOrEvents as string);

        const store: EventHandlersStore = this.getStore(subject);

        let callback: () => void = handler as () => void;

        if (typeof events !== 'string' || !events) {
            store.namespaces().forEach((namespace: string) => {
                this.off(subject, '.' + namespace);
            });

            this.clearStore(subject);

            return this;
        }

        if (callback === undefined && typeof eventsOrCallback === 'function') {
            callback = eventsOrCallback as () => void;
        }

        const isDOMElement: boolean =
                typeof (subject as any).removeEventListener === 'function',
            removeEventListener = (block: EventHandlerBlock) => {
                if (isDOMElement) {
                    (subject as HTMLElement).removeEventListener(
                        block.event,
                        block.syntheticCallback as EventListener,
                        false
                    );
                }
            },
            removeCallbackFromNameSpace = (
                event: string,
                namespace: string
            ) => {
                if (event !== '') {
                    const blocks: EventHandlerBlock[] | void = store.get(
                        event,
                        namespace
                    );
                    if (blocks && blocks.length) {
                        if (typeof callback !== 'function') {
                            blocks.forEach(removeEventListener);
                            blocks.length = 0;
                        } else {
                            const index: number | false = store.indexOf(
                                event,
                                namespace,
                                callback
                            );
                            if (index !== false) {
                                removeEventListener(blocks[index]);
                                blocks.splice(index, 1);
                            }
                        }
                    }
                } else {
                    store.events(namespace).forEach((eventName: string) => {
                        if (eventName !== '') {
                            removeCallbackFromNameSpace(eventName, namespace);
                        }
                    });
                }
            };

        this.eachEvent(
            events,
            (event: string, namespace: string): void => {
                if (namespace === defaultNameSpace) {
                    store.namespaces().forEach((name: string) => {
                        removeCallbackFromNameSpace(event, name);
                    });
                } else {
                    removeCallbackFromNameSpace(event, namespace);
                }
            }
        );

        return this;
    }

    /**
     * Stop execute all another listeners for this event
     *
     * @param subjectOrEvents
     * @param eventsList
     */
    stopPropagation(subjectOrEvents: string): void;
    stopPropagation(subjectOrEvents: object, eventsList: string): void;
    stopPropagation(subjectOrEvents: object | string, eventsList?: string) {
        const subject: object =
            typeof subjectOrEvents === 'string' ? this : subjectOrEvents;

        const events: string =
            typeof subjectOrEvents === 'string'
                ? subjectOrEvents
                : (eventsList as string);

        if (typeof events !== 'string') {
            throw new Error('Need event names');
        }

        const store: EventHandlersStore = this.getStore(subject);

        this.eachEvent(
            events,
            (event: string, namespace: string): void => {
                const blocks: EventHandlerBlock[] | void = store.get(
                    event,
                    namespace
                );

                if (blocks) {
                    this.__stopped.push(blocks);
                }

                if (namespace === defaultNameSpace) {
                    store
                        .namespaces(true)
                        .forEach(ns =>
                            this.stopPropagation(subject, event + '.' + ns)
                        );
                }
            }
        );
    }

    /**
     * Sets the handler for the specified event (Event List) for a given element .
     *
     * @param {object|string} subjectOrEvents - The object which is caused by certain events
     * @param {string|Array} eventsList - List of events , separated by a space or comma
     * @param {Array} [args] - Options for the event handler
     * @return {boolean} `false` if one of the handlers return `false`
     * @example
     * ```javascript
     * var dialog = new Jodit.modules.Dialog();
     * parent.events.on('afterClose', function () {
     *     dialog.destruct(); // will be removed from DOM
     * });
     * dialog.open('Hello world!!!');
     * ```
     *  or you can trigger native browser listener
     *  ```javascript
     *  var events = new Jodit.modules.EventsNative();
     *  events.on(document.body, 'click',function (event) {
     *      alert('click on ' + event.target.id );
     *  });
     *  events.fire(document.body.querySelector('div'), 'click');
     *  ```
     *
     */
    fire(subjectOrEvents: string, eventsList?: any, ...args: any[]): any;
    fire(
        subjectOrEvents: object,
        eventsList: string | Event,
        ...args: any[]
    ): any;
    fire(
        subjectOrEvents: object | string,
        eventsList?: string | any | Event,
        ...args: any[]
    ): any {
        let result: any = void 0,
            result_value: any;

        const subject: object =
            typeof subjectOrEvents === 'string' ? this : subjectOrEvents;

        const events: string =
            typeof subjectOrEvents === 'string'
                ? subjectOrEvents
                : (eventsList as string);

        const argumentsList: any[] =
            typeof subjectOrEvents === 'string' ? [eventsList, ...args] : args;

        const isDOMElement: boolean =
            typeof (subject as any).dispatchEvent === 'function';

        if (!isDOMElement && typeof events !== 'string') {
            throw new Error('Need events names');
        }

        const store: EventHandlersStore = this.getStore(subject);

        if (typeof events !== 'string' && isDOMElement) {
            this.triggerNativeEvent(subject as HTMLElement, eventsList);
        } else {
            this.eachEvent(
                events,
                (event: string, namespace: string): void => {
                    if (isDOMElement) {
                        this.triggerNativeEvent(subject as HTMLElement, event);
                    } else {
                        const blocks: EventHandlerBlock[] | void = store.get(
                            event,
                            namespace
                        );
                        if (blocks) {
                            try {
                                blocks.every(
                                    (block: EventHandlerBlock): boolean => {
                                        if (this.isStopped(blocks)) {
                                            return false;
                                        }

                                        this.current.push(event);

                                        result_value = block.syntheticCallback.apply(
                                            subject,
                                            argumentsList
                                        );

                                        this.current.pop();

                                        if (result_value !== undefined) {
                                            result = result_value;
                                        }

                                        return true;
                                    }
                                );
                            } finally {
                                this.removeStop(blocks);
                            }
                        }

                        if (namespace === defaultNameSpace && !isDOMElement) {
                            store
                                .namespaces()
                                .filter(ns => ns !== namespace)
                                .forEach((ns: string) => {
                                    const result_second: any = this.fire.apply(
                                        this,
                                        [
                                            subject,
                                            event + '.' + ns,
                                            ...argumentsList,
                                        ]
                                    );
                                    if (result_second !== undefined) {
                                        result = result_second;
                                    }
                                });
                        }
                    }
                }
            );
        }

        return result;
    }

    private isDestructed: boolean = false;

    constructor(doc?: Document) {
        if (doc) {
            this.doc = doc;
        }
        this.__key += new Date().getTime();
    }

    destruct() {
        if (!this.isDestructed) {
            return;
        }

        this.isDestructed = true;

        this.off(this);

        this.getStore(this).clear();
        delete (<any>this)[this.__key];
    }
}
