<template>
<div :class="divClass">
    <label v-if="mega">{{title}}<span class="rfo" v-if="rfo">*</span><span class="rfq" v-if="rfq">*</span><span class="aster" v-if="aster">*</span></label>
    <div tabindex="-1" :class="{ 'multiselect--active': isOpen, 'multiselect--disabled': disabled, 'multiselect--above': isAbove }" @focus="activate"
    @blur="deactivate" @keydown.self.down.prevent="pointerForward" @keydown.self.up.prevent="pointerBackward" @keypress.enter.tab.stop.self="addPointerElement($event)"
    @keyup.esc="deactivate" class="multiselect" v-if="editable">
        <slot name="clear" :search="search"></slot>
        <div ref="tags" class="multiselect__tags" :class="{'error': hasError}">
            <input ref="search" :name="id" :id="id" type="text" autocomplete="off" :placeholder="placeholder" style="width:100%" :value="search" :disabled="disableControl" :tabindex="tabindex" @input="updateSearch" @focus.prevent="activate" @blur.prevent="deactivate" @keyup.esc="deactivate" @keydown.down.prevent="pointerForward" @keydown.up.prevent="pointerBackward" @keypress.enter.prevent.stop.self="addPointerElement($event)" class="multiselect__input"/>
            <transition name="multiselect__loading">
                <div v-show="loading" class="multiselect__spinner"/>
            </transition>
        </div>
        <transition name="multiselect">
            <div class="multiselect__content-wrapper" v-show="showSuggestions" @focus="activate" tabindex="-1" @mousedown.prevent :style="{ maxHeight: optimizedHeight + 'px' }" ref="list">
                <ul class="multiselect__content" :style="contentStyle">
                    <slot name="beforeList"></slot>
                    <template>
                        <li class="multiselect__element" v-for="(option, index) of filteredOptions" :key="index">
                            <span v-if="!(option && (option.$isLabel || option.$isDisabled))" :class="optionHighlight(index, option)" @click.stop="select(option)" @mouseenter.self="pointerSet(index)" class="multiselect__option">
                                <slot name="option" :option="option" :search="search">
                                    <span>{{ getOptionLabel(option) }}</span>
                                </slot>
                            </span>
                        </li>
                    </template>
                </ul>
            </div>
        </transition>
    </div>
    <div v-else>
        <span class="noneditable">{{ search }}</span>
    </div>
    <span v-show="editable && hasError" class="vldtn-error" v-html="message || (errors && errors[0])"></span>
</div>
</template>

<script>
import { isEmpty, sortBy, cloneDeep, isObject, isArray, isFunction } from 'lodash'
import { debounce, isPromise, dirtyFilter } from '../../utils/utils'
import pointerMixin from '../combobox/pointerMixin'

function not(fun) {
	return (...params) => !fun(...params)
}

function includesVal(str, query) {
	if (str === undefined) {
		str = 'undefined'
	}
	if (str === null) {
		str = 'null'
	}
	if (str === false) {
		str = 'false'
	}
	const text = str.toString().toLowerCase()
	return text.indexOf(query.toLowerCase().trim()) !== -1
}

function filterOptions(options, search, label, customLabel, filterKey) {
	let filtCond = isArray(filterKey) ? filterKey.join() : filterKey
	if (filterKey && !isEmpty(options) && isObject(options[0])) {
		return dirtyFilter(options, filtCond, search, true)
	} else {
		return options.filter(option => includesVal(customLabel(option, label), search))
	}
}

export default {
    name: 'AutoComplete',
    inject: ['$validator'],
    mixins: [pointerMixin],
    props: {
        /**
        * Limit the display of selected options. The rest will be hidden within the limitText string.
        * @default 99999
        * @type {Integer}
        */
        limit: {
            type: Number,
            default: 99999
        },
        /**
        * Sets maxHeight style value of the dropdown
        * @default 300
        * @type {Integer}
        */
        maxHeight: {
            type: Number,
            default: 200
        },
        /**
         * Function that process the message shown when selected
         * elements pass the defined limit.
         * @default 'and * more'
         * @param {Int} count Number of elements more than limit
         * @type {Function}
         */
        limitText: {
            type: Function,
            default: count => `and ${count} more`
        },
        /**
         * Disables the multiselect if true.
         * @default false
         * @type {Boolean}
         */
        disabled: {
            type: Boolean,
            default: false
        },
        /**
         * Fixed opening direction
         * @default ''
         * @type {String}
         */
        openDirection: {
            type: String,
            default: ''
        },
        tabindex: {
            type: Number,
            default: 0
        },
        // Label text for mega display
        title: {
            default: 'Enter value',
            type: String
        },
        // Mobile compatible material style input
        mega: {
            default: false,
            type: Boolean
        },
        // Required for order style
        rfo: {
            default: false,
            type: Boolean
        },
        // Required for Quote style
        rfq: {
            default: false,
            type: Boolean
        },
        // Show aster mark
        aster: {
            default: false,
            type: Boolean
        },
        // Boolean flag to indicate editable state or not. Displays label instead of input box. Do not confuse this with read only field.
        editable: {
            default: true,
            type: Boolean
        },
        // Error message to be shown when invalid
        message: {
            default: '',
            type: String
        },
        /**
         * Presets the selected options value.
         * @type {Object||Array||String||Integer}
         */
        value: {
            type: null,
            default() {
                return []
            }
        },
        /**
         * Key to compare objects
         * @default 'value'
         * @type {String}
         */
        valueKey: {
            type: String,
            default: undefined
        },
        /**
         * Label to look for in option Object
         * @default 'label'
         * @type {String}
         */
        labelKey: {
            type: String,
            default: undefined
        },
        /**
         * Key to look for in option Object that signifies disabled option to remove
         * @default 'disabled'
         * @type {String}
         */
        disabledKey: {
            type: String,
            default: undefined
        },
        /**
         * Keys on which to sort the options
         * @type {String}
         */
        sortKey: {
            type: String || Array
        },
        /**
         * Keys on which to filter the options
         * @type {String}
         */
        filterKey: {
            type: String || Array
        },
        /**
         * Equivalent to the `placeholder` attribute on a input.
         * @default 'Select option'
         * @type {String}
         */
        placeholder: {
            type: String,
            default: ''
        },
        /**
         * Function to interpolate the custom label on options
         * @default false
         * @type {Function}
         */
        customLabel: {
            type: Function,
            default(option, label) {
                if (isEmpty(option)) {
                    return ''
                }
                return label && isObject(option) ? option[label] : option
            }
        },
        /**
         * Function to interpolate the custom value on selected tag
         * @default false
         * @type {Function}
         */
        customValue: {
            type: Function,
            default(option, label) {
                if (isEmpty(option)) {
                    return ''
                }
                return label && isObject(option) ? option[label] : option
            }
        },
        /**
         * Will be passed with all events as second param.
         * Useful for identifying events origin.
         * @default null
         * @type {String|Integer}
         */
        id: {
            default: null
        },
        /**
         * Limits the options displayed in the dropdown
         * to the first X options.
         * @default 1000
         * @type {Integer}
         */
        optionsLimit: {
            type: Number,
            default: 1000
        },
        /**
         * Array of keyboard keys to block when selecting
         * @default 1000
         * @type {String}
         */
        blockKeystroke: {
            type: Array,
            default() {
                return []
            }
        },
        /**
         * The way in which the component should bind the value
        */
        returnType: {
            type: String,
            default: 'value',
            validate: function (val) {
                return ['value', 'object', 'number'].includes(val)
            }
        },
        /**
         * Source of data which can be an Array or
         * Function which returns array or
         * a Promise that resolves to array
        */
        source: {
            type: [Array, Function]
        },
        /**
         * Number of characters to start suggestions
        */
        startAfter: {
            type: Number,
            default: 3
        }
    },
    computed: {
        divClass() {
            let cls = ['autocomplete']
            if (this.mega) {
                cls.push('megaform')
            } else {
                cls.push('normal')
            }
            if (this.hasError) {
                cls.push('error')
            }
            return cls.join(' ')
        },
        visibleValues () {
            return this.internalValue.slice(0, this.limit)
        },
        contentStyle () {
            return this.optionsLocal.length ? { display: 'inline-block' } : { display: 'block' }
        },
        isAbove () {
            if (this.openDirection === 'above' || this.openDirection === 'top') {
                return true
            } else if (this.openDirection === 'below' || this.openDirection === 'bottom') {
                return false
            } else {
                return this.preferredOpenDirection === 'above'
            }
        },
        showSuggestions () {
            const search = this.search || ''
            const normalizedSearch = search.toLowerCase().trim()
			return this.isOpen && this.filteredOptions.length && normalizedSearch.length >= this.startAfter
        },
        hasError () {
            return !isEmpty(this.message)
        },
        internalValue() {
            return this.value || this.value === 0 ? this.formInternal(this.value) : []
        },
        filteredOptions() {
            const search = this.search || ''
            const normalizedSearch = search.toLowerCase().trim()
            let options = this.optionsLocal.concat()
            if (normalizedSearch.length > this.startAfter) {
                options = filterOptions(options, normalizedSearch, this.labelKey, this.customLabel, this.filterKey)
            }
            return options.slice(0, this.optionsLimit)
        },
        valueKeys() {
            if (this.valueKey) {
                return this.internalValue.map(element => isObject(element) ? element[this.valueKey] : element)
            } else {
                return this.internalValue
            }
        },
        optionKeys() {
            return this.optionsLocal.map(element => this.customLabel(element, this.labelKey).toString().toLowerCase())
        },
        currentOptionLabel() {
            let text = ''
            if (this.internalValue.length) {
                text = this.getOptionLabel(this.internalValue[0])
            }
            return text
        }
    },
    data() {
        return {
            loading: false,
            search: '',
            isOpen: false,
            disableControl: false,
            preferredOpenDirection: 'below',
            optimizedHeight: this.maxHeight,
            optionsLocal: []
        }
    },
    $_veeValidate: {
        name () {
            return this.id
        },
        value () {
            return this.value
        }
    },
    methods: {
        formInternal() {
            let arr = []
            if (isArray(this.value)) {
                if (['value', 'number'].includes(this.returnType)) {
                    this.optionsLocal.forEach(e => {
                        if ((isObject(e) && this.valueKey && this.value.includes(e[this.valueKey])) || this.value.includes(e)) {
                            arr.push(e)
                        }
                    })
                } else {
                    arr = this.value
                }
            } else {
                if (['value', 'number'].includes(this.returnType)) {
                    this.optionsLocal.forEach(e => {
                        if ((isObject(e) && this.valueKey && this.value.includes(e[this.valueKey])) || this.value.includes(e)) {
                            arr.push(e)
                        }
                    })
                } else {
                    arr = [this.value]
                }
            }
            return arr
        },
        /**
         * This method is responsible to sort the options based on sort criteria
        */
        sortOptions(opts) {
            let sortK = this.sortKey ? this.sortKey : this.labelKey
            if (!isArray(sortK)) {
                sortK = sortK.contains(',') ? sortK.split(',') : [sortK]
            }
            opts = sortBy(opts, sortK)
            return opts
        },
        /**
         * Returns the internalValue in a way it can be emited to the parent
         * @returns {Object||Array||String||Integer}
         */
        getValue() {
            return this.internalValue.length === 0 ? null : this.internalValue[0]
        },
        /**
         * Updates the search value
         * @param  {String}
         */
        updateSearch: debounce(function() {
            this.search = this.$refs.search.value ? this.$refs.search.value.trim() : ''
            if (this.search.length == 0) {
                this.removeLastElement()
            } else if (this.search.length > this.startAfter - 2) {
                if (isArray(this.source)) {
                    this.optionsLocal = this.source
                } else if (isFunction(this.source)) {
                    let retObj = this.source(this.search)
                    if (isArray(retObj)) {
                        this.optionsLocal = retObj
                    } else if (isPromise(retObj)) {
                        this.loading = true
                        retObj.then(d => {
                            this.optionsLocal = d
                            this.loading = false
                        })
                    }
                }
            }
        }, 1000),
        /**
         * Finds out if the given query is already present
         * in the available options
         * @param  {String}
         * @returns {Boolean} returns true if element is available
         */
        isExistingOption(query) {
            return !this.optionsLocal ? false : this.optionKeys.indexOf(query) > -1
        },
        /**
         * Finds out if the given element is already present
         * in the result value
         * @param  {Object||String||Integer} option passed element to check
         * @returns {Boolean} returns true if element is selected
         */
        isSelected(option) {
            const opt = isObject(option) ? (this.valueKey ? option[this.valueKey] : option) : option
            return this.valueKeys.indexOf(opt) > -1
        },
        /**
         * Finds out if the given option is disabled
         * @param  {Object||String||Integer} option passed element to check
         * @returns {Boolean} returns true if element is disabled
         */
        isOptionDisabled(option) {
            return !!option.$isDisabled || option[this.disabledKey]
        },
        /**
         * Returns empty string when options is null/undefined
         * Returns tag query if option is tag.
         * Returns the customLabel() results and casts it to string.
         *
         * @param  {Object||String||Integer} Passed option
         * @returns {Object||String}
         */
        getOptionLabel(option) {
            if (isEmpty(option)) {
                return ''
            }
            if (option.isTag) {
                return option.label
            }
            let label = this.customLabel(option, this.labelKey)
            if (isEmpty(label)) {
                return ''
            }
            return label
        },
        /**
         * Returns empty string when options is null/undefined
         * Returns tag query if option is tag.
         * Returns the customValue() results and casts it to string.
         *
         * @param  {Object||String||Integer} Passed option
         * @returns {Object||String}
         */
        getOptionTag(option) {
            if (isEmpty(option)) {
                return ''
            }
            if (option.isTag) {
                return option.label
            }
            let label = this.customValue(option, this.labelKey)
            if (isEmpty(label)) {
                return ''
            }
            return label
        },
        /**
         * Add the given option to the list of selected options
         * or sets the option as the selected option.
         * If option is already selected -> remove it from the results.
         *
         * @param  {Object||String||Integer} option to select/deselect
         * @param  {Boolean} block removing
         */
        select(option, key) {
            if (this.blockKeystroke.indexOf(key) !== -1 || this.disabled || option.$isDisabled || option.$isLabel) {
                return
            }
            if (key === 'Tab' && !this.pointerDirty) {
                return
            }
            const isSelected = this.isSelected(option)
            if (isSelected) {
                if (key !== 'Tab') {
                    this.removeElement(option)
                }
                return
            }
            this.$emit('select', option, this.id)
            this.relayValue(option)
            this.deactivate()
        },
        /**
         * Removes the given option from the selected options.
         *
         * @param  {type} option description
         * @returns {type}        description
         */
        removeElement(option, shouldClose = true) {
            if (this.disabled) {
                return
            }
            if (option.$isDisabled) {
                return
            }
            if (this.internalValue.length < 1) {
                this.deactivate()
                return
            }
            this.$emit('remove', option, this.id)
            this.relayValue(null)
            if (shouldClose) {
                this.deactivate()
            }
        },
        /**
         * Calls this.removeElement() with the last element
         * from this.internalValue (selected element Array)
         *
         * @fires this#removeElement
         */
        removeLastElement() {
            if (this.blockKeystroke.indexOf('Delete') !== -1) {
                return
            }
            if (this.search.length === 0 && Array.isArray(this.internalValue) && this.internalValue.length) {
                this.removeElement(this.internalValue[this.internalValue.length - 1])
            }
        },
        /**
         * Opens the multiselect’s dropdown.
         * Sets this.isOpen to TRUE
         */
        activate() {
            if (this.isOpen || this.disabled) {
                return
            }
            this.adjustPosition()
            this.isOpen = true
            this.$nextTick(() => this.$refs.search.focus())
            this.$emit('open', this.id)
        },
        /**
         * Closes the multiselect’s dropdown.
         * Sets this.isOpen to FALSE
         */
        deactivate() {
            if (!this.isOpen) {
                return
            }
            this.isOpen = false
            this.$refs.search.blur()
            this.$emit('close', this.getValue(), this.id)
        },
        /**
         * Call this.activate() or this.deactivate() depending on this.isOpen value.
         *
         * @fires this#activate || this#deactivate
         * @property {Boolean} isOpen indicates if dropdown is open
         */
        toggle() {
            this.isOpen ? this.deactivate() : this.activate()
        },
        /**
         * Updates the hasEnoughSpace variable used for
         * detecting where to expand the dropdown
         */
        adjustPosition() {
            if (typeof window === 'undefined') {
                return
            }
            const spaceAbove = this.$el.getBoundingClientRect().top
            const spaceBelow = window.innerHeight - this.$el.getBoundingClientRect().bottom
            const hasEnoughSpaceBelow = spaceBelow > this.maxHeight

            if (hasEnoughSpaceBelow || spaceBelow > spaceAbove || this.openDirection === 'below' || this.openDirection === 'bottom') {
                this.preferredOpenDirection = 'below'
                this.optimizedHeight = Math.min(spaceBelow - 40, this.maxHeight)
            } else {
                this.preferredOpenDirection = 'above'
                this.optimizedHeight = Math.min(spaceAbove - 40, this.maxHeight)
            }
        },
        relayValue(obj) {
            if (['value', 'number'].includes(this.returnType) && obj) {
                if (isObject(obj)) {
                    if (this.returnType == 'number') {
                        if (obj[this.valueKey]) {
                            this.$emit('input', Number(obj[this.valueKey]))
                        } else if (obj['value']) {
                            this.$emit('input', Number(obj['value']))
                        } else {
                            this.$emit('input', null)
                        }
                    } else {
                        if (obj[this.valueKey]) {
                            this.$emit('input', obj[this.valueKey])
                        } else if (obj['value']) {
                            this.$emit('input', obj['value'])
                        } else {
                            this.$emit('input', null)
                        }
                    }
                } else {
                    if (this.returnType == 'number') {
                        this.$emit('input', Number(obj))
                    } else {
                        this.$emit('input', obj)
                    }
                }
            } else {
                this.$emit('input', obj)
            }
            this.setSearch(obj)
        },
        setSearch (o) {
            if (isObject(o)) {
                if (o[this.valueKey]) {
                    this.search = o[this.labelKey]
                } else if (o['label']) {
                    this.search = o['label']
                } else {
                    this.search = ''
                }
            } else {
                this.search = o
            }
        }
    },
    mounted() {
        if (!isEmpty(this.value) && isEmpty(this.optionsLocal)) {
            if (isEmpty(this.valueKey)) {
                this.optionsLocal.push(this.value)
                this.setSearch(this.value)
            } else {
                let obj = {}
                obj[this.valueKey] = this.value
                obj[this.labelKey] = this.value
                this.optionsLocal.push(obj)
                this.setSearch(obj)
            }
        }
    },
    watch: {
        search() {
            this.$emit('search-change', this.search, this.id)
        },
        disabled (n) {
            this.disableControl = n
        },
        editable: {
            immediate: true,
            handler: function (n) {
                if (n) {
                    this.$validator.resume()
                } else {
                    this.$validator.pause()
                }
            }
        }
    }
}
</script>
