/**
 * @fileoverview Grid 의 Data Source 에 해당하는 Model 정의
 * @author NHN Ent. FE Development Lab
 */

'use strict';

var _ = require('underscore');
var Backbone = require('backbone');
var snippet = require('tui-code-snippet');

var Model = require('../../base/model');
var ExtraDataManager = require('./extraDataManager');
var GridEvent = require('../../event/gridEvent');

var util = require('../../common/util');
var clipboardUtil = require('../../common/clipboardUtil');
var classNameConst = require('../../common/classNameConst');

// Propertie names that indicate meta data
var PRIVATE_PROPERTIES = [
    '_button',
    '_number',
    '_extraData'
];

// Error code for validtaion
var VALID_ERR_REQUIRED = 'REQUIRED';
var VALID_ERR_TYPE_NUMBER = 'TYPE_NUMBER';

/**
 * Data 중 각 행의 데이터 모델 (DataSource)
 * @module model/data/row
 * @extends module:base/model
 * @ignore
 */
var Row = Model.extend(/** @lends module:model/data/row.prototype */{
    initialize: function() {
        Model.prototype.initialize.apply(this, arguments);
        this.extraDataManager = new ExtraDataManager(this.get('_extraData'));

        this.columnModel = this.collection.columnModel;
        this.validateMap = {};
        this.on('change', this._onChange, this);
    },

    idAttribute: 'rowKey',

    /**
     * Overrides Backbone's set method for executing onBeforeChange before firing change event.
     * @override
     * @param {(Object|string)} key - Model's attribute(s)
     * @param {*} value - Model's value or options when type of key paramater is object
     * @param {?Object} options - The value of key or the options object
     */
    set: function(key, value, options) {
        var isObject = _.isObject(key);
        var changedColumns;

        // When the "key" parameter's type is object,
        // the "options" parameter is replaced by the "value" parameter.
        if (isObject) {
            options = value;
        }

        // When calling set method on initialize, the value of columnModel is undefined.
        if (this.columnModel && !(options && options.silent)) {
            if (isObject) {
                changedColumns = key;
            } else {
                changedColumns = {};
                changedColumns[key] = value;
            }

            _.each(changedColumns, function(columnValue, columnName) {
                if (!this._executeOnBeforeChange(columnName, columnValue)) {
                    delete changedColumns[columnName];
                }
            }, this);

            Backbone.Model.prototype.set.call(this, changedColumns, options);
        } else {
            Backbone.Model.prototype.set.apply(this, arguments);
        }
    },

    /**
     * Overrides Backbone's parse method for extraData not to be null.
     * @override
     * @param  {Object} data - initial data
     * @returns {Object} - parsed data
     */
    parse: function(data) {
        if (!data._extraData) {
            data._extraData = {};
        }

        return data;
    },

    /**
     * Event handler for change event in _extraData.
     * Reset _extraData value with cloned object to trigger 'change:_extraData' event.
     * @private
     */
    _triggerExtraDataChangeEvent: function() {
        this.trigger('extraDataChanged', this.get('_extraData'));
    },

    /**
     * Event handler for change event in _button (=checkbox)
     * @param {boolean} checked - Checked state
     * @private
     */
    _triggerCheckboxChangeEvent: function(checked) {
        var eventObj = {
            rowKey: this.get('rowKey')
        };

        if (checked) {
            /**
             * Occurs when a checkbox in row header is checked
             * @event Grid#check
             * @type {module:event/gridEvent}
             * @property {number} rowKey - rowKey of the checked row
             * @property {Grid} instance - Current grid instance
             */
            this.trigger('check', eventObj);
        } else {
            /**
             * Occurs when a checkbox in row header is unchecked
             * @event Grid#uncheck
             * @type {module:event/gridEvent}
             * @property {number} rowKey - rowKey of the unchecked row
             * @property {Grid} instance - Current grid instance
             */
            this.trigger('uncheck', eventObj);
        }
    },

    /**
     * Event handler for 'change' event.
     * Executes callback functions, sync rowspan data, and validate data.
     * @private
     */
    _onChange: function() {
        var publicChanged = _.omit(this.changed, PRIVATE_PROPERTIES);

        if (_.has(this.changed, '_button')) {
            this._triggerCheckboxChangeEvent(this.changed._button);
        }

        if (this.isDuplicatedPublicChanged(publicChanged)) {
            return;
        }

        _.each(publicChanged, function(value, columnName) {
            var columnModel = this.columnModel.getColumnModel(columnName);

            if (!columnModel) {
                return;
            }

            this.collection.syncRowSpannedData(this, columnName, value);
            this._executeOnAfterChange(columnName);
            this.validateCell(columnName, true);
        }, this);
    },

    /**
     * Validate the cell data of given columnName and returns the error code.
     * @param  {Object} columnName - Column name
     * @returns {String} Error code
     * @private
     */
    _validateCellData: function(columnName) {
        var validation = this.columnModel.getColumnModel(columnName).validation;
        var errorCode = '';
        var value;

        if (validation) {
            value = this.get(columnName);

            if (validation.required && util.isBlank(value)) {
                errorCode = VALID_ERR_REQUIRED;
            } else if (validation.dataType === 'number' && !_.isNumber(value)) {
                errorCode = VALID_ERR_TYPE_NUMBER;
            }
        }

        return errorCode;
    },

    /**
     * Validate a cell of given columnName.
     * If the data is invalid, add 'invalid' class name to the cell.
     * @param {String} columnName - Target column name
     * @param {Boolean} isDataChanged - True if data is changed (called by onChange handler)
     * @returns {String} - Error code
     */
    validateCell: function(columnName, isDataChanged) {
        var errorCode;

        if (!isDataChanged && (columnName in this.validateMap)) {
            return this.validateMap[columnName];
        }

        errorCode = this._validateCellData(columnName);
        if (errorCode) {
            this.addCellClassName(columnName, classNameConst.CELL_INVALID);
        } else {
            this.removeCellClassName(columnName, classNameConst.CELL_INVALID);
        }
        this.validateMap[columnName] = errorCode;

        return errorCode;
    },

    /**
     * Create the GridEvent object when executing changeCallback defined on columnModel
     * @param {String} columnName - Column name
     * @param {?String} columnValue - Column value
     * @returns {GridEvent} Event object to be passed to changeCallback
     * @private
     */
    _createChangeCallbackEvent: function(columnName, columnValue) {
        return new GridEvent(null, {
            rowKey: this.get('rowKey'),
            columnName: columnName,
            value: columnValue,
            instance: this.collection.publicObject
        });
    },

    /**
     * Executes the onChangeBefore callback function.
     * @param {String} columnName - Column name
     * @param {String} columnValue - Column value
     * @returns {boolean}
     * @private
     */
    _executeOnBeforeChange: function(columnName, columnValue) {
        var columnModel = this.columnModel.getColumnModel(columnName);
        var changed = (this.get(columnName) !== columnValue);
        var gridEvent;

        if (changed && columnModel && columnModel.onBeforeChange) {
            gridEvent = this._createChangeCallbackEvent(columnName, columnValue);
            columnModel.onBeforeChange(gridEvent);

            return !gridEvent.isStopped();
        }

        return true;
    },

    /**
     * Execuetes the onAfterChange callback function.
     * @param {String} columnName - Column name
     * @returns {boolean}
     * @private
     */
    _executeOnAfterChange: function(columnName) {
        var columnModel = this.columnModel.getColumnModel(columnName);
        var columnValue = this.get(columnName);
        var gridEvent;

        if (columnModel.onAfterChange) {
            gridEvent = this._createChangeCallbackEvent(columnName, columnValue);
            columnModel.onAfterChange(gridEvent);

            return !gridEvent.isStopped();
        }

        return true;
    },

    /**
     * Returns the Array of private property names
     * @returns {array} An array of private property names
     */
    getPrivateProperties: function() {
        return PRIVATE_PROPERTIES;
    },

    /**
     * Returns the object that contains rowState info.
     * @returns {{disabled: boolean, isDisabledCheck: boolean, isChecked: boolean}} rowState 정보
     */
    getRowState: function() {
        return this.extraDataManager.getRowState();
    },

    /* eslint-disable complexity */
    /**
     * Returns an array of all className, related with given columnName.
     * @param {String} columnName - Column name
     * @returns {Array.<String>} - An array of classNames
     */
    getClassNameList: function(columnName) {
        var columnModel = this.columnModel.getColumnModel(columnName);
        var isMetaColumn = util.isMetaColumn(columnName);
        var classNameList = this.extraDataManager.getClassNameList(columnName);
        var cellState = this.getCellState(columnName);

        if (columnModel.className) {
            classNameList.push(columnModel.className);
        }
        if (columnModel.ellipsis) {
            classNameList.push(classNameConst.CELL_ELLIPSIS);
        }
        if (columnModel.validation && columnModel.validation.required) {
            classNameList.push(classNameConst.CELL_REQUIRED);
        }
        if (isMetaColumn) {
            classNameList.push(classNameConst.CELL_HEAD);
        } else if (cellState.editable) {
            classNameList.push(classNameConst.CELL_EDITABLE);
        }
        if (cellState.disabled) {
            classNameList.push(classNameConst.CELL_DISABLED);
        }

        return this._makeUniqueStringArray(classNameList);
    },
    /* eslint-enable complexity */

    /**
     * Returns a new array, which splits all comma-separated strings in the targetList and removes duplicated item.
     * @param  {Array} targetArray - Target array
     * @returns {Array} - New array
     */
    _makeUniqueStringArray: function(targetArray) {
        var singleStringArray = _.uniq(targetArray.join(' ').split(' '));

        return _.without(singleStringArray, '');
    },

    /**
     * Returns the state of the cell identified by a given column name.
     * @param {String} columnName - column name
     * @returns {{editable: boolean, disabled: boolean}}
     */
    getCellState: function(columnName) {
        var notEditableTypeList = ['_number', 'normal'],
            columnModel = this.columnModel,
            disabled = this.collection.disabled,
            editable = true,
            editType = columnModel.getEditType(columnName),
            rowState, relationResult;

        relationResult = this.executeRelationCallbacksAll(['disabled', 'editable'])[columnName];
        rowState = this.getRowState();

        if (!disabled) {
            if (columnName === '_button') {
                disabled = rowState.disabledCheck;
            } else {
                disabled = rowState.disabled;
            }
            disabled = disabled || !!(relationResult && relationResult.disabled);
        }

        if (_.contains(notEditableTypeList, editType)) {
            editable = false;
        } else {
            editable = !(relationResult && relationResult.editable === false);
        }

        return {
            editable: editable,
            disabled: disabled
        };
    },

    /**
     * Returns whether the cell identified by a given column name is editable.
     * @param {String} columnName - column name
     * @returns {Boolean}
     */
    isEditable: function(columnName) {
        var cellState = this.getCellState(columnName);

        return !cellState.disabled && cellState.editable;
    },

    /**
     * Returns whether the cell identified by a given column name is disabled.
     * @param {String} columnName - column name
     * @returns {Boolean}
     */
    isDisabled: function(columnName) {
        var cellState = this.getCellState(columnName);

        return cellState.disabled;
    },

    /**
     * getRowSpanData
     * rowSpan 설정값을 반환한다.
     * @param {String} [columnName] 인자가 존재하지 않을 경우, 행 전체의 rowSpanData 를 맵 형태로 반환한다.
     * @returns {*|{count: number, isMainRow: boolean, mainRowKey: *}}   rowSpan 설정값
     */
    getRowSpanData: function(columnName) {
        var isRowSpanEnable = this.collection.isRowSpanEnable(),
            rowKey = this.get('rowKey');

        return this.extraDataManager.getRowSpanData(columnName, rowKey, isRowSpanEnable);
    },

    /**
     * Returns the _extraData.height
     * @returns {number}
     */
    getHeight: function() {
        return this.extraDataManager.getHeight();
    },

    /**
     * Sets the height of the row
     * @param {number} height - height
     */
    setHeight: function(height) {
        this.extraDataManager.setHeight(height);
        this._triggerExtraDataChangeEvent();
    },

    /**
     * rowSpanData를 설정한다.
     * @param {string} columnName - 컬럼명
     * @param {Object} data - rowSpan 정보를 가진 객체
     */
    setRowSpanData: function(columnName, data) {
        this.extraDataManager.setRowSpanData(columnName, data);
        this._triggerExtraDataChangeEvent();
    },

    /**
     * rowState 를 설정한다.
     * @param {string} rowState 해당 행의 상태값. 'DISABLED|DISABLED_CHECK|CHECKED' 중 하나를 설정한다.
     * @param {boolean} silent 내부 change 이벤트 발생 여부
     */
    setRowState: function(rowState, silent) {
        this.extraDataManager.setRowState(rowState);
        if (!silent) {
            this._triggerExtraDataChangeEvent();
        }
    },

    /**
     * rowKey 와 columnName 에 해당하는 Cell 에 CSS className 을 설정한다.
     * @param {String} columnName 컬럼 이름
     * @param {String} className 지정할 디자인 클래스명
     */
    addCellClassName: function(columnName, className) {
        this.extraDataManager.addCellClassName(columnName, className);
        this._triggerExtraDataChangeEvent();
    },

    /**
     * rowKey에 해당하는 행 전체에 CSS className 을 설정한다.
     * @param {String} className 지정할 디자인 클래스명
     */
    addClassName: function(className) {
        this.extraDataManager.addClassName(className);
        this._triggerExtraDataChangeEvent();
    },

    /**
     * rowKey 와 columnName 에 해당하는 Cell 에 CSS className 을 제거한다.
     * @param {String} columnName 컬럼 이름
     * @param {String} className 지정할 디자인 클래스명
     */
    removeCellClassName: function(columnName, className) {
        this.extraDataManager.removeCellClassName(columnName, className);
        this._triggerExtraDataChangeEvent();
    },

    /**
     * rowKey 에 해당하는 행 전체에 CSS className 을 제거한다.
     * @param {String} className 지정할 디자인 클래스명
     */
    removeClassName: function(className) {
        this.extraDataManager.removeClassName(className);
        this._triggerExtraDataChangeEvent();
    },

    /**
     * ctrl + c 로 복사 기능을 사용할 때 list 형태(select, button, checkbox)의 cell 의 경우, 해당 value 에 부합하는 text로 가공한다.
     * List type 의 경우 데이터 값과 editOptions.listItems 의 text 값이 다르기 때문에
     * text 로 전환해서 반환할 때 처리를 하여 변환한다.
     *
     * @param {string} columnName - Column name
     * @param {boolean} useText - Whether returns concatenated text or values
     * @returns {string} Concatenated text or values of "listItems" option
     * @private
     */
    _getStringOfListItems: function(columnName, useText) {
        var value = this.get(columnName);
        var columnModel = this.columnModel.getColumnModel(columnName);
        var resultListItems, editOptionList, typeExpected, valueList, hasListItems;

        if (snippet.isExisty(snippet.pick(columnModel, 'editOptions', 'listItems'))) {
            resultListItems = this.executeRelationCallbacksAll(['listItems'])[columnName];
            hasListItems = resultListItems && resultListItems.listItems;
            editOptionList = hasListItems ? resultListItems.listItems : columnModel.editOptions.listItems;

            typeExpected = typeof editOptionList[0].value;
            valueList = util.toString(value).split(',');

            if (typeExpected !== typeof valueList[0]) {
                valueList = _.map(valueList, function(val) {
                    return util.convertValueType(val, typeExpected);
                });
            }

            _.each(valueList, function(val, index) {
                var item = _.findWhere(editOptionList, {value: val});
                var str = (item && (useText ? item.text : item.value)) || '';

                valueList[index] = str;
            }, this);

            return valueList.join(',');
        }

        return '';
    },

    /**
     * Returns whether the given edit type is list type.
     * @param {String} editType - edit type
     * @returns {Boolean}
     * @private
     */
    _isListType: function(editType) {
        return _.contains(['select', 'radio', 'checkbox'], editType);
    },

    /**
     * change 이벤트 발생시 동일한 changed 객체의 public 프라퍼티가 동일한 경우 중복 처리를 막기 위해 사용한다.
     * 10ms 내에 같은 객체로 함수 호출이 일어나면 true를 반환한다.
     * @param {Object} publicChanged 비교할 객체
     * @returns {boolean} 중복이면 true, 아니면 false
     */
    isDuplicatedPublicChanged: function(publicChanged) {
        if (this._timeoutIdForChanged && _.isEqual(this._lastPublicChanged, publicChanged)) {
            return true;
        }
        clearTimeout(this._timeoutIdForChanged);
        this._timeoutIdForChanged = setTimeout(_.bind(function() {
            this._timeoutIdForChanged = null;
        }, this), 10); // eslint-disable-line no-magic-numbers
        this._lastPublicChanged = publicChanged;

        return false;
    },

    /**
     * Returns the text string to be used when copying the cell value to clipboard.
     * @param {string} columnName - column name
     * @returns {string}
     */
    getValueString: function(columnName) {
        var columnModel = this.columnModel;
        var copyText = columnModel.copyVisibleTextOfEditingColumn(columnName);
        var editType = columnModel.getEditType(columnName);
        var column = columnModel.getColumnModel(columnName);
        var value = this.get(columnName);

        if (this._isListType(editType)) {
            if (snippet.isExisty(snippet.pick(column, 'editOptions', 'listItems', 0, 'value'))) {
                value = this._getStringOfListItems(columnName, copyText);
            } else {
                throw new Error('Check "' + columnName +
                    '"\'s editOptions.listItems property out in your ColumnModel.');
            }
        } else if (editType === 'password') {
            value = '';
        }

        value = util.toString(value);

        // When the value is indcluding newline text,
        // adding one more quotation mark and putting quotation marks on both sides.
        value = clipboardUtil.addDoubleQuotes(value);

        return value;
    },

    /**
     * 컬럼모델에 정의된 relation 들을 수행한 결과를 반환한다. (기존 affectOption)
     * @param {Array} attrNames 반환값의 결과를 확인할 대상 callbackList.
     *        (default : ['listItems', 'disabled', 'editable'])
     * @returns {{}|{columnName: {attribute: *}}} row 의 columnName 에 적용될 속성값.
     */
    executeRelationCallbacksAll: function(attrNames) {
        var rowData = this.attributes;
        var relationsMap = this.columnModel.get('relationsMap');
        var result = {};

        if (_.isEmpty(attrNames)) {
            attrNames = ['listItems', 'disabled', 'editable'];
        }

        _.each(relationsMap, function(relations, columnName) {
            var value = rowData[columnName];

            _.each(relations, function(relation) {
                this._executeRelationCallback(relation, attrNames, value, rowData, result);
            }, this);
        }, this);

        return result;
    },

    /**
     * Executes relation callback
     * @param {Object} relation - relation object
     *   @param {array} relation.targetNames - target column list
     *   @param {function} [relation.disabled] - callback function for disabled attribute
     *   @param {function} [relation.editable] - callback function for disabled attribute
     *   @param {function} [relation.listItems] - callback function for changing option list
     * @param {array} attrNames - an array of callback names
     * @param {(string|number)} value - cell value
     * @param {Object} rowData - all value of the row
     * @param {Object} result - object to store the result of callback functions
     * @private
     */
    _executeRelationCallback: function(relation, attrNames, value, rowData, result) {
        var rowState = this.getRowState();
        var targetNames = relation.targetNames;

        _.each(attrNames, function(attrName) {
            var callback;

            if (!rowState.disabled || attrName !== 'disabled') {
                callback = relation[attrName];
                if (typeof callback === 'function') {
                    _.each(targetNames, function(targetName) {
                        result[targetName] = result[targetName] || {};
                        result[targetName][attrName] = callback(value, rowData);
                    }, this);
                }
            }
        }, this);
    }
}, {
    privateProperties: PRIVATE_PROPERTIES
});

module.exports = Row;