/**
 * @module data/Store
 */
'use strict';

import {readCookie, writeCookie} from '../utils/cookie';
import TinyEmitter from 'tiny-emitter';
import _ from 'lodash';
import EventBus from '../utils/EventBus';
import {htmlTagRegEx, eventBeforeStoreReload, eventAfterStoreReload} from '../../utils/constants';
import {getHighlightRegExp, searchStringToRegExp} from '../utils/regexp';

/**
 * @alias data/Store:Store
 */
export default class Store extends TinyEmitter {
    /**
     * Example configurations:
     * ```json
     * {
     *     "fields": [],
     *     "fields": {},
     *     "stateName": ""
     * }
     * ```
     *
     * @param {object} params
     */
    constructor(params) {
        super();

        this.primaryKey = 'id';
        this.storeType = params.storeType;

        this.setFields(params.fields);

        this.loaded = false;
        this.data = [];
        this.rawData = [];
        this.totalCount = 0;
        this.filteredCount = 0;

        this.filteringFields = this.fields.filter(field => !!field.textFilterHandler);

        this.params = {};
        this.setInitialTextFilter('');
        this.setInitialFieldsFilter([]);

        // a property to be used to keep pagination and sorting information
        this.stateName = params.stateName ? 'state_' + params.stateName : null;

        let stateProps = this.readState();
        let initialPageSize = undefined;
        if (stateProps && stateProps.pageSize) {
            initialPageSize = stateProps.pageSize;
        }

        this.setInitialSorting(this.fields[0].id, 'ASC');
        this.setInitialPagination(0, initialPageSize);

        this.textFilterCallback = text => {
            if (this.textFilterPattern === null) {
                return true;
            } else {
                this.textFilterPattern.lastIndex = 0;
                let clearText = getClearText(text);
                return this.textFilterPattern.test(clearText);
            }
        };

        let debounceValue = this.useBackend()
            ? this.getBackendDebounceValue()
            : this.getClientDebounceValue();

        this.debouncedInternalLoad = _.debounce(this.internalLoad, debounceValue);
    }

    clone() {
        let copiedStore = _.cloneDeep(this);
        copiedStore.e = null; // drop event listeners from copy
        return copiedStore;
    }

    /**
     * Registers the store in Event Bus. This allows remote control over certain store types.
     * @param type
     */
    register(type) {
        this.storeType = type;
        this.onStoreReset = (storeType, details) => {
            if (!storeType || type === storeType) {
                this.reload();

                if (details && details.removedIds) {
                    this.emit('deleteItems', details.removedIds);
                }
            }
        };

        EventBus.on('resetStore', this.onStoreReset);
    }

    /**
     * Unwires store from EventBus and cancels all pending activities
     */
    destructor() {
        if (this.onStoreReset) {
            EventBus.off('resetStore', this.onStoreReset);
        }

        this.debouncedInternalLoad.cancel();
        this.endLoading();
    }

    /**
     * Returns a field descriptor by ID
     * @param fieldId
     */
    getFieldById(fieldId) {
        return this.fields.filter(field => field.id === fieldId)[0];
    }

    /**
     * Resets page back to zero
     * TODO Should emit change event?
     */
    resetCurrentPage() {
        this.params.currentPage = 0;
    }

    /**
     * @deprecated
     * @param filter
     */
    setInitialFieldsFilter(filter) {
        this.params.customFieldsFilter = filter;
    }

    /**
     * Use {@link data/Store.setTextFilter} instead
     * @deprecated
     * @param searchString
     */
    setInitialTextFilter(searchString) {
        this.setTextFilter(searchString);
    }

    /**
     * TODO Should emit change event?
     * @param {string} searchString
     */
    setTextFilter(searchString) {
        this.params.filterByText = searchString;
        this.textFilterPattern = Store.searchStringToRegExp(searchString);
        this.textHighlightPattern = Store.getHighlightRegExp(searchString);
    }

    /**
     * @deprecated
     * @param {string} fieldId
     * @param {string} direction
     */
    setInitialSorting(fieldId, direction = 'ASC') {
        let state = this.readState(); // use values from state
        if (state) {
            if (state.sortBy && this.getFieldById(state.sortBy)) {
                fieldId = state.sortBy;
            }
            if (state.sortDir) {
                direction = state.sortDir;
            }
        }

        this.params.sorting = {
            fieldId: fieldId,
            direction: direction
        };
    }

    /**
     * @deprecated
     * @param currentPage
     * @param pageSize
     */
    setInitialPagination(currentPage, pageSize = null) {
        this.params.currentPage = currentPage;
        this.params.pageSize = pageSize;
    }

    setItems(items, skipConversion) {
        this.startLoading();

        let data = skipConversion ? items : this.convert(items);

        this.pool = Promise.resolve(data);
        this.calculate(data);
    }

    getItemsCollection(converter) {
        let items = {};
        let data = this.useBackend() ? this.getData() : this.getRawData();
        data.forEach(item => {
            items[item[this.primaryKey]] = converter(_.cloneDeep(item));
        });
        return items;
    }

    extendFormState(items, converter) {
        return _.merge(this.getItemsCollection(converter), items);
    }

    /**
     * This method will be used if useBackend = true
     * @return {Promise.<Array>}
     */
    loadWithParams() {
        return Promise.resolve({data: [], filteredCount: 0, totalCount: 0});
    }

    /**
     * This method will be used if useBackend = false
     * @return {Promise.<Array>}
     */
    loadAll() {
        return Promise.resolve([]);
    }

    load() {
        this.startLoading();
        this.debouncedInternalLoad();
    }

    /**
     * TODO Add _ before name
     * Emits before load event
     * @protected
     */
    startLoading() {
        if (!this.awaiting) {
            this.awaiting = true;
            EventBus.emit(eventBeforeStoreReload, this.storeType);
        }
    }

    /**
     * TODO Add _ before name
     * Emits after load event
     * @protected
     */
    endLoading() {
        this.loaded = true;
        if (this.awaiting) {
            this.awaiting = false;
            EventBus.emit(eventAfterStoreReload, this.storeType);
        }
    }

    /**
     * TODO Add _ before name
     * Actual load function that calls backend/frontend loaders and manages calculations
     * @protected
     * @param forced
     */
    internalLoad(forced) {
        if (this.useBackend()) {
            this.loadWithParams()
                .then(({data, filteredCount, totalCount, lastPage}) => {
                    this.data = this.convert(data);
                    this.filteredCount = filteredCount;
                    this.totalCount = totalCount;

                    // todo move to more common way?
                    if (lastPage !== undefined) {
                        this.lastPage = lastPage;
                    }

                    this.notifyChanges();
                })
                .catch(error => {
                    this.calculate([]);
                    throw error;
                });
        } else {
            if (forced || !this.pool) {
                this.pool = this.loadAll().then(data => {
                    return this.convert(data);
                });
            }

            this.pool
                .then(data => {
                    this.calculate(data);
                })
                .catch(error => {
                    // eslint-disable-next-line
                    console.error(error.stack || error);
                    this.calculate([]);
                });
        }
    }

    reload() {
        this.internalLoad(true);
    }

    convert(data) {
        return data.map(item => {
            let result = {};
            this.fields.forEach(field => {
                result[field.id] = field.getter(item);
            });
            return result;
        });
    }

    setFilteringFields(fields) {
        if (typeof fields === 'string') {
            fields = [].slice.call(arguments, 0);
        }

        this.filteringFields = fields.map(this.getFieldById, this);
    }

    static getDefaultComparator(type) {
        switch (type) {
            case String:
            case 'string':
                return Store.compareStrings;
            case Number:
            case 'number':
                return Store.compareNumbers;
            case 'enum':
                return (a, b) => a.value - b.value;
            //case 'date': ...

            default:
                return (a, b) => a - b;
        }
    }

    static compareStrings(a, b, direction) {
        if (!a && !b) {
            return 0;
        }
        if ((!a && direction === 'ASC') || (!b && direction === 'DESC')) {
            return 1;
        }
        if ((!b && direction === 'ASC') || (!a && direction === 'DESC')) {
            return -1;
        }
        return a.localeCompare(b, {}, {numeric: true, sensitivity: 'base'});
    }

    static compareNumbers(a, b, direction) {
        const aIsValid = typeof a === 'number' && !isNaN(a);
        const bIsValid = typeof b === 'number' && !isNaN(b);
        if (!aIsValid && !bIsValid) {
            return 0;
        }
        if ((!aIsValid && direction === 'ASC') || (!bIsValid && direction === 'DESC')) {
            return 1;
        }
        if ((!bIsValid && direction === 'ASC') || (!aIsValid && direction === 'DESC')) {
            return -1;
        }
        if (a < b) {
            return -1;
        }
        if (a > b) {
            return 1;
        }
        return 0;
    }

    static defaultFilter(value, callback) {
        return callback(value);
    }

    static getDefaultTextFilterHandler(type) {
        switch (type) {
            case 'enum':
                return (fieldValue, callback) =>
                    Store.defaultFilter(fieldValue.description, callback);

            default:
                return Store.defaultFilter;
        }
    }

    static getDefaultFieldFilterHandler(type) {
        switch (type) {
            case 'enum':
                return (fieldValue, callback) => Store.defaultFilter(fieldValue.value, callback);
            default:
                return Store.defaultFilter;
        }
    }

    static getFilterByAction(action, filterValue) {
        switch (action) {
            case 'in':
                return value =>
                    _.intersection(
                        Array.isArray(value) ? value : [value],
                        Array.isArray(filterValue) ? filterValue : [filterValue]
                    ).length > 0;

            default:
                // eslint-disable-next-line
                return value => value == filterValue;
        }
    }

    // TODO: ...
    useBackend() {
        return false;
    }

    getClientDebounceValue() {
        return 150;
    }

    getBackendDebounceValue() {
        return 500;
    }

    setFields(fields) {
        if (fields instanceof Array) {
            this.setFieldsFromArray(fields);
        } else if (fields instanceof Object) {
            this.setFieldsFromObject(fields);
        } else {
            throw new TypeError('Store: argument "fields" should be Object or Array');
        }
        if (!this.fields.some(field => field.id === this.primaryKey)) {
            // eslint-disable-next-line
            console.error(
                'Store "%s" cannot find primary key "%s" in fields definition',
                this.constructor.name,
                this.primaryKey
            ); //TODO Throw?
        }
    }

    setFieldsFromArray(fields) {
        if (!fields.length) {
            throw new Error('Store: empty fields declaration');
        }

        this.fields = fields.map(field => {
            let type = field.type || 'string';

            if (field.isPrimaryKey) {
                this.primaryKey = field.id;
            }

            let res = {
                id: field.id,
                type: type,
                getter: field.getter || (item => item[field.id]),
                comparator: field.comparator || Store.getDefaultComparator(type),
                textFilterHandler:
                    field.textFilterHandler || Store.getDefaultTextFilterHandler(type),
                fieldFilterHandler:
                    field.fieldFilterHandler || Store.getDefaultFieldFilterHandler(type)
            };
            if (field.field) {
                res.field = field.field;
            }
            return res;
        });
    }

    setFieldsFromObject(fields) {
        this.setFieldsFromArray(
            Object.keys(fields).map(function(id) {
                let field = {id: id};
                if (typeof fields[id] === 'object') {
                    field.type = getType(fields[id].type);
                    field.isPrimaryKey = fields[id].primaryKey || fields[id].isPrimaryKey;
                    field.getter = fields[id].getter;
                    field.comparator = fields[id].comparator;
                    field.textFilterHandler = fields[id].textFilterHandler;
                    field.fieldFilterHandler = fields[id].fieldFilterHandler;
                    if (fields[id].field) {
                        field.field = fields[id].field;
                    }
                } else {
                    field.type = getType(fields[id]);
                }

                return field;
            })
        );
    }

    // TODO rename to setCurrentPageAndReload
    setCurrentPage(index) {
        this.params.currentPage = index;
        this.load();
    }

    setPageSize(size) {
        this.writeState({pageSize: size});

        this.params.pageSize = size;
        this.resetCurrentPage();

        this.load();
    }

    // @private
    writeState(attrs) {
        if (this.stateName) {
            let state = this.readState();
            state = state ? _.merge(state, attrs) : attrs; // merge with existed
            writeCookie(this.stateName, JSON.stringify(state), '/');
        }
    }

    // @private
    readState() {
        let state;
        if (this.stateName) {
            let cookieValues = readCookie(this.stateName);
            state = JSON.parse(cookieValues);
        }

        return state;
    }

    sortBy(fieldId, direction) {
        if (!direction) {
            direction = 'ASC';
        }

        this.params.sorting = {
            fieldId: fieldId,
            direction: direction
        };
        this.writeState({
            sortBy: fieldId,
            sortDir: direction
        });

        this.resetCurrentPage();

        this.load();
    }

    filterByText(searchString) {
        this.setTextFilter(searchString);
        this.resetCurrentPage();

        this.load();
    }

    filterByFields(fields) {
        this.params.customFieldsFilter = fields;
        this.resetCurrentPage();

        this.load();
    }

    filterByField(fieldName, ...filter) {
        let currentFilterClean = this.getCurrentFilter(fieldName);
        let fieldFilter = [];

        if (filter.length === 2) {
            let [action, value] = filter;

            fieldFilter.push({
                field: fieldName,
                action: action,
                value: value
            });
        } else {
            let value = filter[0];

            if (typeof value !== 'object') {
                fieldFilter.push({
                    field: fieldName,
                    value: value
                });
            } else {
                fieldFilter = fieldFilter.concat(value);
                fieldFilter = fieldFilter.map(item => _.extend({field: fieldName}, item));
            }
        }

        this.filterByFields(currentFilterClean.concat(fieldFilter));
    }

    resetFieldFilter(fieldName) {
        this.filterByFields(this.getCurrentFilter(fieldName));
    }

    getCurrentFilter(excludedFieldName) {
        return this.params.customFieldsFilter.filter(item => item.field !== excludedFieldName);
    }

    hasFilters() {
        return !!(
            this.params.filterByText ||
            (this.params.customFieldsFilter && this.params.customFieldsFilter.length)
        );
    }

    getFiltered(list) {
        // Filter by fields
        if (this.params.customFieldsFilter.length) {
            this.params.customFieldsFilter.forEach(filter => {
                list = list.filter(item => {
                    let field = this.getFieldById(filter.field);
                    let fieldValue = item[filter.field];

                    let operator = Store.getFilterByAction(filter.action, filter.value);
                    let isPassed = field.fieldFilterHandler(fieldValue, operator);

                    return filter.inverted ? !isPassed : isPassed;
                });
            });
        }

        // Filter by text
        if (this.params.filterByText.length && this.filteringFields.length) {
            list = list.filter(item => {
                return this.filteringFields.some(field =>
                    field.textFilterHandler(item[field.id], this.textFilterCallback)
                ); //TODO Add full item
            });
        }

        return list;
    }

    getSorted(list) {
        let {fieldId, direction} = this.params.sorting;
        let comparator = this.getFieldById(fieldId).comparator;

        return mergeSort(list, (a, b) => {
            return comparator(a[fieldId], b[fieldId], direction) * (direction === 'ASC' ? 1 : -1);
        });
    }

    getSliced(list) {
        this.validateCurrentPage(list);

        let from = this.params.currentPage * this.params.pageSize || 0;
        let to = this.params.pageSize === null ? undefined : from + this.params.pageSize;

        return list.slice(from, to);
    }

    validateCurrentPage(list) {
        let maxPageIndex = Math.max(0, Math.ceil(list.length / this.params.pageSize) - 1);
        this.params.currentPage = Math.min(this.params.currentPage, maxPageIndex);
    }

    calculate(data) {
        let hasRawDataChanges = !_.isEqual(this.rawData, data);

        this.totalCount = data.length;
        this.rawData = data;

        let list = this.getFiltered(data);
        this.filteredCount = list.length;

        list = this.getSorted(list);
        list = this.getSliced(list);

        this.data = list;

        this.notifyChanges(hasRawDataChanges);
    }

    notifyChanges(hasRawDataChanges) {
        this.endLoading();
        this.emit('change', this.getData(), hasRawDataChanges);
    }

    onChange(handler, context) {
        this.on('change', handler, context);
    }

    onDeleteItems(handler, context) {
        this.on('deleteItems', handler, context);
    }

    getTotalCount() {
        return this.totalCount;
    }

    getIsLastPage() {
        return !!this.lastPage;
    }

    getFilteredCount() {
        return this.filteredCount;
    }

    getData() {
        return this.data;
    }

    getRawData() {
        if (this.useBackend()) {
            throw new Error(
                'Method getRawData is unavailable for backend store; use getData instead of getRawData'
            );
        }
        return this.rawData;
    }

    getPrimaryKeys() {
        return this.data.map(item => item[this.primaryKey]);
    }

    getCount() {
        return this.data.length;
    }

    getCurrentPage() {
        return this.params.currentPage;
    }

    getPageSize() {
        return this.params.pageSize;
    }

    getSorting() {
        return this.params.sorting;
    }

    getSortingFieldId() {
        return this.params.sorting.fieldId;
    }

    getSortingDirection() {
        return this.params.sorting.direction;
    }

    isAwaiting() {
        return !!this.awaiting;
    }

    isLoaded() {
        return !!this.loaded;
    }

    static searchStringToRegExp = searchStringToRegExp;

    static getHighlightRegExp = getHighlightRegExp;
}

// We need this for stable sorting, because default Array.prototype.sort provides unstable sort results in Chrome
let mergeSort = (function() {
    function mergeSortInner(arr, compare) {
        let length = arr.length;
        let middle = Math.floor(length / 2);

        // Default "compare" method has been removed since we do not have any method calls without second argument.
        if (length < 2) {
            return arr;
        }

        return merge(
            mergeSortInner(arr.slice(0, middle), compare),
            mergeSortInner(arr.slice(middle, length), compare),
            compare
        );
    }

    function merge(left, right, compare) {
        let result = [];

        while (left.length > 0 || right.length > 0) {
            if (left.length > 0 && right.length > 0) {
                if (compare(left[0], right[0]) <= 0) {
                    result.push(left[0]);
                    left = left.slice(1);
                } else {
                    result.push(right[0]);
                    right = right.slice(1);
                }
            } else if (left.length > 0) {
                result.push(left[0]);
                left = left.slice(1);
            } else if (right.length > 0) {
                result.push(right[0]);
                right = right.slice(1);
            }
        }
        return result;
    }

    return mergeSortInner;
})();

function getType(type) {
    switch (type) {
        case 'number':
        case Number:
            return 'number';
        case 'string':
        case String:
            return 'string';
        case 'boolean':
        case Boolean:
            return 'boolean';
        default:
            return 'custom';
    }
}

function getClearText(text) {
    return String(text).replace(htmlTagRegEx, '');
}
