// NG2
import { Component, EventEmitter, forwardRef, ElementRef, ViewChild, ViewContainerRef, Input, Output, OnInit, AfterViewInit, OnDestroy, NgZone } from '@angular/core';
import { NG_VALUE_ACCESSOR } from '@angular/forms';
// APP
import { OutsideClick } from './../../utils/outside-click/OutsideClick';
import { KeyCodes } from './../../utils/key-codes/KeyCodes';
import { QuickNoteResults } from './extras/quick-note-results/QuickNoteResults';
import { ComponentUtils } from './../../utils/component-utils/ComponentUtils';

// Value accessor for the component (supports ngModel)
const QUICK_NOTE_VALUE_ACCESSOR = {
    provide: NG_VALUE_ACCESSOR,
    useExisting: forwardRef(() => QuickNoteElement),
    multi: true
};

declare var CKEDITOR: any;

@Component({
    selector: 'novo-quick-note',
    providers: [QUICK_NOTE_VALUE_ACCESSOR],
    template: `
        <div class="quick-note-wrapper" #wrapper>
            <textarea #host></textarea>
            <span #results></span>
        </div>
    `
})
export class QuickNoteElement extends OutsideClick implements OnInit, OnDestroy, AfterViewInit {
    // The quick-note-wrapper that contains the text area and results
    @ViewChild('wrapper') public wrapper: ElementRef;

    // The textarea host for CKEditor
    @ViewChild('host') public host: ElementRef;

    // Picker dropdown results container
    @ViewChild('results', { read: ViewContainerRef }) results: ViewContainerRef;

    // The config object that customizes the Quick Note behavior
    @Input() config: any;

    // The placeholder text when the value in the text field is empty
    @Input() placeholder: string;

    // Emitter for selects
    @Output() focus: EventEmitter<any> = new EventEmitter();
    @Output() blur: EventEmitter<any> = new EventEmitter();
    @Output() change: EventEmitter<any> = new EventEmitter();

    // The characters that the user enters in order to search for a person/thing to tag
    private resultsComponent: any;
    private quickNoteResults: any;
    private isTagging: boolean;
    private taggingMode: string;
    private model: any;
    private ckeInstance: any;
    private debounceTimeout: any;
    private placeholderVisible: boolean = false;
    private _placeholderElement: any = null;

    private static TOOLBAR_HEIGHT = 40; // in pixels - configured by stylesheet

    private onModelChange: Function = () => {
    };
    private onModelTouched: Function = () => {
    };

    constructor(private zone: NgZone, element: ElementRef, private componentUtils: ComponentUtils) {
        super(element);
        // Bind to the active change event from the OutsideClick
        this.onActiveChange.subscribe(active => {
            if (!active) {
                setTimeout(() => {
                    this.hideResults();
                });
            }
        });
    }

    public ngOnInit(): void {
        // Make sure we have a proper config
        if (!this.config) {
            throw new Error('No config set for QuickNote!');
        }
        // Make sure that we have triggers
        if (!this.config.triggers) {
            throw new Error('QuickNote config must supply triggers!');
        }
        // Make sure that we have options
        if (!this.config.options) {
            throw new Error('QuickNote config must supply options!');
        }
        // Allow for callers to use a custom results template class in the config
        this.resultsComponent = this.config.resultsTemplate || QuickNoteResults;
    }

    public ngOnDestroy(): void {
        // Tear down the CKEditor instance
        if (this.ckeInstance) {
            this.ckeInstance.focusManager.blur(true); // Remove focus from editor
            setTimeout(() => {
                this.ckeInstance.removeAllListeners();
                CKEDITOR.instances[this.ckeInstance.name].destroy();
                this.ckeInstance.destroy();
                this.ckeInstance = null;
            });
        }
    }

    /**
     * Connect to key/mouse events from CKEditor after the editor has been initialized
     */
    public ngAfterViewInit(): void {
        if (!CKEDITOR) {
            console.error('Make sure to include CKEditor sources in your dependencies!');
            return;
        }

        // Replace the textarea with an instance of CKEditor
        this.ckeInstance = CKEDITOR.replace(this.host.nativeElement, this.getCKEditorConfig());

        // Set initial value of the note in the editor
        this.writeValue(this.model);

        // Connect to the key event in CKEditor for showing results dropdown
        this.ckeInstance.on('key', (event: any) => {
            if (!this.onKey(event.data.domEvent.$)) {
                event.cancel();
            }
        });

        // Connect to the change event in CKEditor for debouncing user modifications
        this.ckeInstance.on('change', () => {
            // Debounce update
            if (this.debounceTimeout) {
                clearTimeout(this.debounceTimeout);
            }
            this.debounceTimeout = setTimeout(() => {
                // Run within the context of this angular element since we don't need to cancel event
                this.zone.run(() => {
                    this.onValueChange();
                });
                this.debounceTimeout = null;
            }, 250);
        });

        // Propagate blur events from CKEditor to the Element's listeners
        this.ckeInstance.on('blur', (event: any) => {
            this.showPlaceholder();
            this.blur.emit(event);
        });

        // Propagate blur events from CKEditor to the Element's listeners
        this.ckeInstance.on('focus', (event: any) => {
            this.hidePlaceholder();
            this.focus.emit(event);
        });

        // Show placeholder if the note is empty, after the editor is instantiated
        this.ckeInstance.on('instanceReady', (event: any) => {
            this.showPlaceholder();
        });
    }

    // Set touched on blur
    public onTouched(event?: any) {
        this.onModelTouched();
    }

    /**
     * Handles setting the model and the view from the outside caller or the user's typing
     *
     * @param model A model that has a note (html content) and references (array of objects)
     */
    public writeValue(model: any): void {
        // Set value of the model
        if (model && (model.references || model.note)) {
            this.model = {
                note: model.note || '',
                references: model.references || {}
            };
        } else {
            this.model = {
                note: model,
                references: {}
            };
        }

        // Set the note html value in the editor
        if (this.ckeInstance) {
            this.ckeInstance.setData(this.model.note);
        }
    }

    public registerOnChange(fn: Function): void {
        this.onModelChange = fn;
    }

    public registerOnTouched(fn: Function): void {
        this.onModelTouched = fn;
    }

    /**
     * If a renderer is not provided, the QuickNote will default to using this one, an anchor tag with no href
     */
    private static defaultRenderer(symbol: string, item: any): string {
        return `<a>${symbol}${item.label}</a>`;
    }

    /**
     * Returns the renderer for a given tagging mode if it exists in the config, otherwise the default.
     */
    private getRenderer(taggingMode: string): any {
        return this.config.renderer ? this.config.renderer[taggingMode] : QuickNoteElement.defaultRenderer;
    }

    /**
     * Called every time a keystroke is made in the editor. Listens for particular keys (e.g. UP arrow, ESC, etc.)
     * to handle certain behaviors of the picker.
     *
     * Runs within the context of the CKEditor, so actions that affect the view have to be run back inside of the
     * Angular zone of this class.
     *
     * @param event The key press event
     * @return true to allow the event to occur, false to cancel the event
     */
    private onKey(event: KeyboardEvent): boolean {
        if (event.key) {
            if (this.quickNoteResults) {
                // Hide results on escape key
                if (event.keyCode === KeyCodes.ESC) {
                    this.zone.run(() => {
                        this.hideResults();
                    });
                    return false;
                }

                // Navigation inside the results
                if (event.keyCode === KeyCodes.UP) {
                    this.zone.run(() => {
                        this.quickNoteResults.instance.prevActiveMatch();
                    });
                    return false;
                }

                if (event.keyCode === KeyCodes.DOWN) {
                    this.zone.run(() => {
                        this.quickNoteResults.instance.nextActiveMatch();
                    });
                    return false;
                }

                if (event.keyCode === KeyCodes.ENTER) {
                    this.zone.run(() => {
                        this.quickNoteResults.instance.selectActiveMatch();
                    });
                    return false;
                }
            } else {
                // Loop through all triggers and turn on tagging mode if the user just pressed a trigger character
                let triggers = this.config.triggers || {};
                Object.keys(triggers).forEach(key => {
                    let trigger = triggers[key] || {};
                    if (event.key === trigger) {
                        this.isTagging = true;
                        this.taggingMode = key;
                    }
                });
            }
        }
        return true;
    }

    /**
     * Debounced method that is run in the proper Angular context when the user has modified the CKEditor.
     * After the value has been updated in CKEditor, this will propagate that change to the model and listeners.
     */
    private onValueChange(): void {
        // Get the html text in CKEditor
        let value = this.ckeInstance.getData();

        // Remove empty 'ZERO WIDTH SPACE' characters that can get added erroneously by the editor
        let regex = new RegExp(String.fromCharCode(8203), 'g');
        value = value.replace(regex, '');

        // Make sure that any references in the model are still valid
        this.validateReferences();

        // Possibly show results if the user has entered a search term
        this.showResults();

        // Propagate change to ngModel for form validation, and send null if the note is empty
        let newModel = null;
        if (value) {
            newModel = {
                note: value,
                references: this.model.references
            };
        }

        // Inform listeners to the ngModel change event that something has changed
        this.onModelChange(newModel);

        // Inform listeners of the `@Output() change` event that the model has been updated
        this.change.emit(newModel);

        // Inform listeners to the ngModel touched event that something has changed
        this.onTouched();
    }

    /**
     * Creates an instance of the results (called popup) and adds all the bindings to that instance.
     */
    private showResults(): void {
        if (this.isTagging) {
            let searchTerm = this.getSearchTerm();
            if (searchTerm.length) {
                // Update Matches
                if (this.quickNoteResults) {
                    // Update existing list
                    this.quickNoteResults.instance.term = {
                        searchTerm: searchTerm,
                        taggingMode: this.taggingMode
                    };
                } else {
                    // Create the results DOM element
                    this.quickNoteResults = this.componentUtils.appendNextToLocation(this.resultsComponent, this.results);
                    this.quickNoteResults.instance.parent = this;
                    this.quickNoteResults.instance.config = this.config;
                    this.quickNoteResults.instance.term = {
                        searchTerm: searchTerm,
                        taggingMode: this.taggingMode
                    };
                    this.positionResultsDropdown();
                }
            } else if (this.quickNoteResults) {
                this.quickNoteResults.destroy();
                this.quickNoteResults = null;
            }

            // Tell the OutsideClick base class to start listening for an outside clicks
            this.toggleActive(null, true);
        }
    }

    /**
     * Deletes the picker results from the DOM.
     */
    private hideResults(): void {
        this.isTagging = false;
        if (this.quickNoteResults) {
            this.quickNoteResults.destroy();
            this.quickNoteResults = null;
        }
    }

    /**
     * Handles the selection from the QuickNoteResults Component. Called by the QuickNoteResults component on it's
     * parent (this element).
     *
     * @param taggingMode - type of tags we are looking for
     * @param selected - selected object from the picker that has a label and value
     */
    private onSelected(taggingMode: string, selected: any): void {
        // Turn off tagging
        this.isTagging = false;

        // Replace searchTerm with link
        let symbol = this.config.triggers[taggingMode];
        let renderer = this.getRenderer(taggingMode);
        let renderedText = renderer(symbol, selected);

        this.replaceWordAtCursor(renderedText);

        // Add the new reference, if it doesn't already exist
        this.model.references = this.model.references || {};
        this.model.references[taggingMode] = this.model.references[taggingMode] || [];
        let matchingItems = this.model.references[taggingMode].filter(item => JSON.stringify(item) === JSON.stringify(selected));
        if (matchingItems.length === 0) {
            this.model.references[taggingMode].push(selected);
        }

        // Update the quick note with the changes due to the user's selection of an item in the dropdown
        this.onValueChange();
    }

    /**
     * Convenience method that gets the current word that the cursor is on, minus the tag.
     * Also, trims any whitespace before/after the term to aid in searching.
     */
    private getSearchTerm(): string {
        let word = this.getWordAtCursor().trim();
        if (this.isTagging) {
            let symbol = this.config.triggers[this.taggingMode];
            if (!word.includes(symbol)) {
                this.hideResults();
                return '';
            }
            word = word.slice(word.indexOf(symbol) + symbol.length);
        }
        return word;
    }

    /**
     * Gets the current word that the cursor is on CKEditor. Current word starts at the beginning of the line or a
     * tag character if we are in tagging mode. Current word ends at the end of the line or an empty space.
     *
     * @returns plain text string (removes all html formatting)
     */
    private getWordAtCursor(): string {
        let range = this.ckeInstance.getSelection().getRanges()[0];
        let start = range.startContainer;

        if (start.type === CKEDITOR.NODE_TEXT && range.startOffset) {
            let text = start.getText();
            let symbol = this.config.triggers[this.taggingMode];
            let wordStart = text.lastIndexOf(symbol, range.startOffset - 1);
            let wordEnd = text.indexOf(' ', range.startOffset + 1);
            if (wordStart === -1) {
                wordStart = 0;
            }
            if (wordEnd === -1) {
                wordEnd = text.length;
            }

            return text.substring(wordStart, wordEnd);
        }

        // Selection starts at the 0 index of the text node or there's no previous text node in contents
        return '';
    }

    /**
     * Replaces the word that the user is on with the given html.
     *
     * CKEditor gives us access to the current line of html in the editor, so we replace the content of
     * the line, replacing only the current word.
     */
    private replaceWordAtCursor(newWord: string): void {
        let originalWord = this.getWordAtCursor().trim();
        let range = this.ckeInstance.getSelection().getRanges()[0];
        let start = range.startContainer;
        let parentNode = start.getParent();

        if (start.type === CKEDITOR.NODE_TEXT && parentNode) {
            let line = parentNode.getHtml();
            let index = line.lastIndexOf(originalWord);

            if (index >= 0) {
                // Add a space after the replaced word so that multiple references can be added back to back
                let newLine = line.substring(0, index) + newWord + ' ' + line.substring(index + originalWord.length);
                parentNode.setHtml(newLine);

                // Place selection at the end of the line
                range.moveToPosition(parentNode, CKEDITOR.POSITION_BEFORE_END);
                this.ckeInstance.getSelection().selectRanges( [ range ] );
            }
        }
    }

    /**
     * Returns current references, minus any from the model that have been removed from the editor.
     */
    private validateReferences(): void {
        let html = this.ckeInstance.document.getBody().getHtml();

        // CKEditor stopped supporting the config.forceSimpleAmpersand setting, so we have to convert '&amp;' to '&'
        // when we pull html from the editor - see: https://dev.ckeditor.com/ticket/13723
        let ampRegex = new RegExp('&amp;', 'g');
        html = html.replace(ampRegex, '&');

        Object.keys(this.model.references).forEach(taggingMode => {
            let array = this.model.references[taggingMode] || [];
            let symbol = this.config.triggers[taggingMode];
            let renderer = this.getRenderer(taggingMode);

            this.model.references[taggingMode] = array.filter(item => {
                let renderedText = renderer(symbol, item);
                return html.includes(renderedText);
            });

            // If no references, then delete the key
            if (this.model.references[taggingMode].length === 0) {
                delete this.model.references[taggingMode];
            }
        });
    }

    /**
     * Configures the CKEditor for QuickNote functionality.
     *
     * Sets the height of the CKEditor dynamically to the height of the wrapper upon initialization.
     * Removes the toolbar on the bottom and configures a slimmed down version of the toolbar.
     * Removes plugins and turns off setting to allow browser based spell checking.
     */
    private getCKEditorConfig(): any {
        let editorHeight = this.wrapper.nativeElement.clientHeight - QuickNoteElement.TOOLBAR_HEIGHT;

        return {
            enterMode: CKEDITOR.ENTER_BR,
            shiftEnterMode: CKEDITOR.ENTER_P,
            disableNativeSpellChecker: false,
            height: editorHeight,
            removePlugins: 'elementspath,liststyle,tabletools,contextmenu',
            resize_enabled: false, // hides the status bar
            toolbar: [{
                name: 'basicstyles',
                items: ['Styles', 'FontSize', 'Bold', 'Italic', 'Underline', 'TextColor', '-', 'NumberedList', 'BulletedList', 'Outdent', 'Indent', 'Link']
            }]
        };
    }

    /**
     * Returns the current screen position of the cursor in CKEditor, accounting for any scrolling in the editor.
     *
     * @returns {{top: number, left: number}}
     */
    private getCursorPosition(): any {
        let range = this.ckeInstance.getSelection().getRanges()[0];
        let parentElement = range.startContainer.$.parentElement;
        let editorElement = this.ckeInstance.editable().$;

        // Since the editor is a text node in the DOM that does not know about it's position, a temporary element has to
        // be inserted in order to locate the cursor position.
        let cursorElement = document.createElement('img');
        cursorElement.setAttribute('src', 'null');
        cursorElement.setAttribute('width', '0');
        cursorElement.setAttribute('height', '0');

        parentElement.appendChild(cursorElement);
        let cursorPosition = {
            top: cursorElement.offsetTop - editorElement.scrollTop,
            left: cursorElement.offsetLeft - editorElement.scrollLeft
        };
        cursorElement.remove();

        return cursorPosition;
    }

    /**
     * Positions the results dropdown based on the location of the cursor in the text field
     */
    private positionResultsDropdown(): void {
        const DROPDOWN_OFFSET: number = 30; // The distance between the cursor and the dropdown
        const MIN_MARGIN_TOP: number = DROPDOWN_OFFSET;
        const MAX_MARGIN_TOP: number = this.ckeInstance.config.height + QuickNoteElement.TOOLBAR_HEIGHT;

        let cursorPosition = this.getCursorPosition();
        let marginTop: number = cursorPosition.top + QuickNoteElement.TOOLBAR_HEIGHT + DROPDOWN_OFFSET;

        // Check that the margin is within the visible bounds
        marginTop = Math.max(marginTop, MIN_MARGIN_TOP);
        marginTop = Math.min(marginTop, MAX_MARGIN_TOP);

        // Set the margin-top of the dropdown
        this.quickNoteResults.instance.element.nativeElement.style.setProperty('margin-top', marginTop + 'px');
    }

    /**
     * Show the placeholder text if the editor is empty
     */
    private showPlaceholder(): void {
        if (!this.ckeInstance.getData()) {
            this.ckeInstance.editable().getParent().$.appendChild(this.placeholderElement);
            this.placeholderVisible = true;
        }
    }

    /**
     * Hide the placeholder text by removing the placeholder element from the DOM
     */
    private hidePlaceholder(): void {
        if (this.placeholderVisible) {
            this.ckeInstance.editable().getParent().$.removeChild(this.placeholderElement);
            this.placeholderVisible = false;
        }
    }

    /**
     * Get or create the single placeholder object that is constructed only when needed.
     */
    private get placeholderElement(): any {
        if (!this._placeholderElement) {
            this._placeholderElement = document.createElement('div');
            this._placeholderElement.className = 'placeholder';
            this._placeholderElement.style.cssText = 'margin: 20px; color: #AAAAAA; font-family: sans-serif; font-size: 13px; line-height: 20px; position: absolute; top: 0';
            this._placeholderElement.textContent = this.placeholder;
        }
        return this._placeholderElement;
    }
}
