/**
 * Copyright (c) 2016 Denis Taran
 * 
 * Homepage: https://smartscheduling.com/en/documentation/autocomplete
 * Source: https://github.com/denis-taran/autocomplete
 * 
 * MIT License
 */

export const enum EventTrigger {
    Keyboard = 0,
    Focus = 1,
    Mouse = 2,
    /**
     * Fetch is triggered manually by calling `fetch` function returned in `AutocompleteResult`
     */
    Manual = 3
}

/**
 * Enum for controlling form submission when `ENTER` key is pressed in the autocomplete input field.
 */
export const enum PreventSubmit {
    Never = 0,
    Always = 1,
    /**
     * Form submission is prevented only when an item is selected from the autocomplete list.
     */
    OnSelect = 2
}

export interface AutocompleteItem {
    label?: string;
    group?: string;
}

export interface AutocompleteEvent<T extends Event> {
    /**
     * Native event object passed by browser to the event handler
     */
    event: T;

    /**
     * Fetch data and display autocomplete
     */
    fetch: () => void;
}

export interface AutocompleteSettings<T extends AutocompleteItem> {
    /**
     * Autocomplete will be attached to this element.
     */
    input: HTMLInputElement | HTMLTextAreaElement;

    /**
     * Provide your own container for the widget.
     * If not specified, a new DIV element will be created.
     */
    container?: HTMLDivElement;

    /**
     * This method allows you to override the default rendering function for items.
     * It must return a DIV element or undefined to skip rendering.
     */
    render?: (item: T, currentValue: string, index: number) => HTMLDivElement | undefined;

    /**
     * This method allows you to override the default rendering function for item groups.
     * It must return a DIV element or undefined to skip rendering.
     */
    renderGroup?: (name: string, currentValue: string) => HTMLDivElement | undefined;

    /**
     * If specified, the autocomplete DOM element will have this class assigned to it.
     */
    className?: string;

    /**
     * Specify the minimum text length required to show autocomplete.
     */
    minLength?: number;

    /**
     * The message that will be showed when there are no suggestions that match the entered value.
     */
    emptyMsg?: string;

    /**
     * This method will be called when user choose an item in autocomplete. The selected item will be passed as the first parameter.
     */
    onSelect: (item: T, input: HTMLInputElement | HTMLTextAreaElement) => void;

    /**
     * Show autocomplete on focus event. Focus event will ignore the `minLength` property and will always call `fetch`.
     */
    showOnFocus?: boolean;

    /**
     * This method will be called to prepare suggestions and then pass them to autocomplete.
     * @param {string} text - text in the input field
     * @param {(items: T[] | false) => void} update - a callback function that must be called after suggestions are prepared
     * @param {EventTrigger} trigger - type of the event that triggered the fetch
     * @param {number} cursorPos - position of the cursor in the input field
     */
    fetch: (text: string, update: (items: T[] | false) => void, trigger: EventTrigger, cursorPos: number) => void;

    /**
     * Enforces that the fetch function will only be called once within the specified time frame (in milliseconds) and
     * delays execution. This prevents flooding your server with AJAX requests.
     */
    debounceWaitMs?: number;

    /**
     * Callback for additional autocomplete customization
     * @param {HTMLInputElement | HTMLTextAreaElement} input - input box associated with autocomplete
     * @param {ClientRect | DOMRect} inputRect - size of the input box and its position relative to the viewport
     * @param {HTMLDivElement} container - container with suggestions
     * @param {number} maxHeight - max height that can be used by autocomplete
     */
    customize?: (input: HTMLInputElement | HTMLTextAreaElement, inputRect: ClientRect | DOMRect, container: HTMLDivElement, maxHeight: number) => void;

    /**
     * Controls form submission when the ENTER key is pressed in a input field.
     */
    preventSubmit?: PreventSubmit;

    /**
     * Prevents the first item in the list from being selected automatically. This option allows you
     * to submit a custom text by pressing ENTER even when autocomplete is displayed.
     */
    disableAutoSelect?: boolean;

    /**
     * Provide your keyup event handler to display autocomplete when a key is pressed that doesn't modify the content. You can also perform some additional actions.
     */
    keyup?: (e: AutocompleteEvent<KeyboardEvent>) => void;

    /**
     * Allows to display autocomplete on mouse clicks or perform some additional actions.
     */
    click?: (e: AutocompleteEvent<MouseEvent>) => void;
}

export interface AutocompleteResult {
    /**
     * Remove event handlers, DOM elements and ARIA/accessibility attributes created by the widget.
     */
    destroy: () => void;

    /**
     * This function allows to manually start data fetching and display autocomplete. Note that
     * it does not automatically place focus on the input field, so you may need to do so manually
     * in certain situations.
     */
    fetch: () => void;
}

export default function autocomplete<T extends AutocompleteItem>(settings: AutocompleteSettings<T>): AutocompleteResult {

    // just an alias to minimize JS file size
    const doc = document;

    const container: HTMLDivElement = settings.container || doc.createElement('div');
    const preventSubmit: PreventSubmit = settings.preventSubmit || PreventSubmit.Never;

    container.id = container.id || 'autocomplete-' + uid();
    const containerStyle = container.style;
    const debounceWaitMs = settings.debounceWaitMs || 0;
    const disableAutoSelect = settings.disableAutoSelect || false;
    const customContainerParent = container.parentElement;

    let items: T[] = [];
    let inputValue = '';
    let minLen = 2;
    const showOnFocus = settings.showOnFocus;
    let selected: T | undefined;
    let fetchCounter = 0;
    let debounceTimer: number | undefined;
    let destroyed = false;

    // Fixes #104: autocomplete selection is broken on Firefox for Android
    let suppressAutocomplete = false;

    if (settings.minLength !== undefined) {
        minLen = settings.minLength;
    }

    if (!settings.input) {
        throw new Error('input undefined');
    }

    const input: HTMLInputElement | HTMLTextAreaElement = settings.input;

    container.className = [container.className, 'autocomplete', settings.className || ''].join(' ').trim();
    container.setAttribute('role', 'listbox');

    input.setAttribute('role', 'combobox');
    input.setAttribute('aria-expanded', 'false');
    input.setAttribute('aria-autocomplete', 'list');
    input.setAttribute('aria-controls', container.id);
    input.setAttribute('aria-owns', container.id);
    input.setAttribute('aria-activedescendant', '');
    input.setAttribute('aria-haspopup', 'listbox');

    // IOS implementation for fixed positioning has many bugs, so we will use absolute positioning
    containerStyle.position = 'absolute';

    /**
     * Generate a very complex textual ID that greatly reduces the chance of a collision with another ID or text.
     */
    function uid(): string {
        return Date.now().toString(36) + Math.random().toString(36).substring(2);
    }

    /**
     * Detach the container from DOM
     */
    function detach() {
        const parent = container.parentNode;
        if (parent) {
            parent.removeChild(container);
        }
    }

    /**
     * Clear debouncing timer if assigned
     */
    function clearDebounceTimer() {
        if (debounceTimer) {
            window.clearTimeout(debounceTimer);
        }
    }

    /**
     * Attach the container to DOM
     */
    function attach() {
        if (!container.parentNode) {
            (customContainerParent || doc.body).appendChild(container);
        }
    }

    /**
     * Check if container for autocomplete is displayed
     */
    function containerDisplayed(): boolean {
        return !!container.parentNode;
    }

    /**
     * Clear autocomplete state and hide container
     */
    function clear() {
        // prevent the update call if there are pending AJAX requests
        fetchCounter++;

        items = [];
        inputValue = '';
        selected = undefined;
        input.setAttribute('aria-activedescendant', '');
        input.setAttribute('aria-expanded', 'false');
        detach();
    }

    /**
     * Update autocomplete position
     */
    function updatePosition() {
        if (!containerDisplayed()) {
            return;
        }

        input.setAttribute('aria-expanded', 'true');

        containerStyle.height = 'auto';
        containerStyle.width = input.offsetWidth + 'px';

        let maxHeight = 0;
        let inputRect: DOMRect | undefined;

        function calc() {
            const docEl = doc.documentElement as HTMLElement;
            const clientTop = docEl.clientTop || doc.body.clientTop || 0;
            const clientLeft = docEl.clientLeft || doc.body.clientLeft || 0;
            const scrollTop = window.pageYOffset || docEl.scrollTop;
            const scrollLeft = window.pageXOffset || docEl.scrollLeft;

            inputRect = input.getBoundingClientRect();

            const top = inputRect.top + input.offsetHeight + scrollTop - clientTop;
            const left = inputRect.left + scrollLeft - clientLeft;

            containerStyle.top = top + 'px';
            containerStyle.left = left + 'px';

            maxHeight = window.innerHeight - (inputRect.top + input.offsetHeight);

            if (maxHeight < 0) {
                maxHeight = 0;
            }

            containerStyle.top = top + 'px';
            containerStyle.bottom = '';
            containerStyle.left = left + 'px';
            containerStyle.maxHeight = maxHeight + 'px';
        }

        // the calc method must be called twice, otherwise the calculation may be wrong on resize event (chrome browser)
        calc();
        calc();

        if (settings.customize && inputRect) {
            settings.customize(input, inputRect, container, maxHeight);
        }
    }

    /**
     * Redraw the autocomplete div element with suggestions
     */
    function update() {

        container.textContent = '';
        input.setAttribute('aria-activedescendant', '');

        // function for rendering autocomplete suggestions
        let render = function (item: T, _: string, __: number): HTMLDivElement | undefined {
            const itemElement = doc.createElement('div');
            itemElement.textContent = item.label || '';
            return itemElement;
        };
        if (settings.render) {
            render = settings.render;
        }

        // function to render autocomplete groups
        let renderGroup = function (groupName: string, _: string): HTMLDivElement | undefined {
            const groupDiv = doc.createElement('div');
            groupDiv.textContent = groupName;
            return groupDiv;
        };
        if (settings.renderGroup) {
            renderGroup = settings.renderGroup;
        }

        const fragment = doc.createDocumentFragment();
        let prevGroup = uid();

        items.forEach(function (item: T, index: number): void {
            if (item.group && item.group !== prevGroup) {
                prevGroup = item.group;
                const groupDiv = renderGroup(item.group, inputValue);
                if (groupDiv) {
                    groupDiv.className += ' group';
                    fragment.appendChild(groupDiv);
                }
            }
            const div = render(item, inputValue, index);
            if (div) {
                div.id = `${container.id}_${index}`;
                div.setAttribute('role', 'option');
                div.addEventListener('click', function (ev: MouseEvent): void {
                    suppressAutocomplete = true;
                    try {
                        settings.onSelect(item, input);
                    } finally {
                        suppressAutocomplete = false;
                    }
                    clear();
                    ev.preventDefault();
                    ev.stopPropagation();
                });
                if (item === selected) {
                    div.className += ' selected';
                    div.setAttribute('aria-selected', 'true');
                    input.setAttribute('aria-activedescendant', div.id);
                }
                fragment.appendChild(div);
            }
        });
        container.appendChild(fragment);
        if (items.length < 1) {
            if (settings.emptyMsg) {
                const empty = doc.createElement('div');
                empty.id = `${container.id}_${uid()}`;
                empty.className = 'empty';
                empty.textContent = settings.emptyMsg;
                container.appendChild(empty);
                input.setAttribute('aria-activedescendant', empty.id);
            } else {
                clear();
                return;
            }
        }

        attach();
        updatePosition();

        updateScroll();
    }

    function updateIfDisplayed() {
        if (containerDisplayed()) {
            update();
        }
    }

    function resizeEventHandler() {
        updateIfDisplayed();
    }

    function scrollEventHandler(e: Event) {
        if (e.target !== container) {
            updateIfDisplayed();
        } else {
            e.preventDefault();
        }
    }

    function inputEventHandler() {
        if (!suppressAutocomplete) {
            fetch(EventTrigger.Keyboard);
        }
    }

    /**
     * Automatically move scroll bar if selected item is not visible
     */
    function updateScroll() {
        const elements = container.getElementsByClassName('selected');
        if (elements.length > 0) {
            let element = elements[0] as HTMLDivElement;

            // make group visible
            const previous = element.previousElementSibling as HTMLDivElement;
            if (previous && previous.className.indexOf('group') !== -1 && !previous.previousElementSibling) {
                element = previous;
            }

            if (element.offsetTop < container.scrollTop) {
                container.scrollTop = element.offsetTop;
            } else {
                const selectBottom = element.offsetTop + element.offsetHeight;
                const containerBottom = container.scrollTop + container.offsetHeight;
                if (selectBottom > containerBottom) {
                    container.scrollTop += selectBottom - containerBottom;
                }
            }
        }
    }

    function selectPreviousSuggestion() {
        const index = items.indexOf(selected!);

        selected = index === -1
            ? undefined
            : items[(index + items.length - 1) % items.length];

        updateSelectedSuggestion(index);
    }

    function selectNextSuggestion() {
        const index = items.indexOf(selected!);

        selected = items.length < 1
            ? undefined
            : index === -1
                ? items[0]
                : items[(index + 1) % items.length];

        updateSelectedSuggestion(index);
    }

    function updateSelectedSuggestion(index: number) {
        if (items.length > 0) {
            unselectSuggestion(index);
            selectSuggestion(items.indexOf(selected!));
            updateScroll();
        }
    }

    function selectSuggestion(index: number) {
        var element = doc.getElementById(container.id + "_" + index);
        if (element) {
            element.classList.add('selected');
            element.setAttribute('aria-selected', 'true');
            input.setAttribute('aria-activedescendant', element.id);
        }
    }

    function unselectSuggestion(index: number) {
        var element = doc.getElementById(container.id + "_" + index);
        if (element) {
            element.classList.remove('selected');
            element.removeAttribute('aria-selected');
            input.removeAttribute('aria-activedescendant');
        }
    }

    function handleArrowAndEscapeKeys(ev: KeyboardEvent, key: 'ArrowUp' | 'ArrowDown' | 'Escape') {
        const containerIsDisplayed = containerDisplayed();

        if (key === 'Escape') {
            clear();
        } else {
            if (!containerIsDisplayed || items.length < 1) {
                return;
            }
            key === 'ArrowUp'
                ? selectPreviousSuggestion()
                : selectNextSuggestion();
        }

        ev.preventDefault();

        if (containerIsDisplayed) {
            ev.stopPropagation();
        }
    }

    function handleEnterKey(ev: KeyboardEvent) {
        if (selected) {
            if (preventSubmit === PreventSubmit.OnSelect) {
                ev.preventDefault();
            }
            suppressAutocomplete = true;
            try {
                settings.onSelect(selected, input);
            } finally {
                suppressAutocomplete = false;
            }
            clear();
        }

        if (preventSubmit === PreventSubmit.Always) {
            ev.preventDefault();
        }
    }

    function keydownEventHandler(ev: KeyboardEvent) {
        const key = ev.key;

        switch (key) {
            case 'ArrowUp':
            case 'ArrowDown':
            case 'Escape':
                handleArrowAndEscapeKeys(ev, key);
                break;
            case 'Enter':
                handleEnterKey(ev);
                break;
            default:
                break;
        }
    }

    function focusEventHandler() {
        if (showOnFocus) {
            fetch(EventTrigger.Focus);
        }
    }

    function fetch(trigger: EventTrigger) {
        if (input.value.length >= minLen || trigger === EventTrigger.Focus) {
            clearDebounceTimer();
            debounceTimer = window.setTimeout(
                () => startFetch(input.value, trigger, input.selectionStart || 0),
                trigger === EventTrigger.Keyboard || trigger === EventTrigger.Mouse ? debounceWaitMs : 0);
        } else {
            clear();
        }
    }

    function startFetch(inputText: string, trigger: EventTrigger, cursorPos: number) {
        if (destroyed) return;
        const savedFetchCounter = ++fetchCounter;
        settings.fetch(inputText, function (elements: T[] | false): void {
            if (fetchCounter === savedFetchCounter && elements) {
                items = elements;
                inputValue = inputText;
                selected = (items.length < 1 || disableAutoSelect) ? undefined : items[0];
                update();
            }
        }, trigger, cursorPos);
    }

    function keyupEventHandler(e: KeyboardEvent) {
        if (settings.keyup) {
            settings.keyup({
                event: e,
                fetch: () => fetch(EventTrigger.Keyboard)
            });
            return;
        }

        if (!containerDisplayed() && e.key === 'ArrowDown') {
            fetch(EventTrigger.Keyboard);
        }
    }

    function clickEventHandler(e: MouseEvent) {
        settings.click && settings.click({
            event: e,
            fetch: () => fetch(EventTrigger.Mouse)
        });
    }

    function blurEventHandler() {
        // when an item is selected by mouse click, the blur event will be initiated before the click event and remove DOM elements,
        // so that the click event will never be triggered. In order to avoid this issue, DOM removal should be delayed.
        setTimeout(() => {
            if (doc.activeElement !== input) {
                clear();
            }
        }, 200);
    }

    function manualFetch() {
        startFetch(input.value, EventTrigger.Manual, input.selectionStart || 0);
    }

    /**
     * Fixes #26: on long clicks focus will be lost and onSelect method will not be called
     */
    container.addEventListener('mousedown', function (evt: Event) {
        evt.stopPropagation();
        evt.preventDefault();
    });

    /**
     * Fixes #30: autocomplete closes when scrollbar is clicked in IE
     * See: https://stackoverflow.com/a/9210267/13172349
     */
    container.addEventListener('focus', () => input.focus());

    // If the custom autocomplete container is already appended to the DOM during widget initialization, detach it.
    detach();

    /**
     * This function will remove DOM elements and clear event handlers
     */
    function destroy() {
        input.removeEventListener('focus', focusEventHandler);
        input.removeEventListener('keyup', keyupEventHandler as EventListenerOrEventListenerObject)
        input.removeEventListener('click', clickEventHandler as EventListenerOrEventListenerObject)
        input.removeEventListener('keydown', keydownEventHandler as EventListenerOrEventListenerObject);
        input.removeEventListener('input', inputEventHandler as EventListenerOrEventListenerObject);
        input.removeEventListener('blur', blurEventHandler);
        window.removeEventListener('resize', resizeEventHandler);
        doc.removeEventListener('scroll', scrollEventHandler, true);
        input.removeAttribute('role');
        input.removeAttribute('aria-expanded');
        input.removeAttribute('aria-autocomplete');
        input.removeAttribute('aria-controls');
        input.removeAttribute('aria-activedescendant');
        input.removeAttribute('aria-owns');
        input.removeAttribute('aria-haspopup');
        clearDebounceTimer();
        clear();
        destroyed = true;
    }

    // setup event handlers
    input.addEventListener('keyup', keyupEventHandler as EventListenerOrEventListenerObject);
    input.addEventListener('click', clickEventHandler as EventListenerOrEventListenerObject);
    input.addEventListener('keydown', keydownEventHandler as EventListenerOrEventListenerObject);
    input.addEventListener('input', inputEventHandler as EventListenerOrEventListenerObject);
    input.addEventListener('blur', blurEventHandler);
    input.addEventListener('focus', focusEventHandler);
    window.addEventListener('resize', resizeEventHandler);
    doc.addEventListener('scroll', scrollEventHandler, true);

    return {
        destroy,
        fetch: manualFetch
    };
}
