/**
 * Plugin (ptype = 'rowexpander') that adds the ability to have a Column in a grid which enables
 * a second row body which expands/contracts.  The expand/contract behavior is configurable to react
 * on clicking of the column, double click of the row, and/or hitting enter while a row is selected.
 *
 * **Note:** The {@link Ext.grid.plugin.RowExpander rowexpander} plugin and the rowbody
 * feature are exclusive and cannot both be set on the same grid / tree.
 */
Ext.define('Ext.grid.plugin.RowExpander', {
    extend: 'Ext.plugin.Abstract',
    lockableScope: 'top',
 
    requires: [
        'Ext.grid.feature.RowBody'
    ],
 
    alias: 'plugin.rowexpander',
 
    /**
     * @cfg {Number} [columnWidth=24]
     * The width of the row expander column which contains the [+]/[-] icons to toggle
     * row expansion.
     */
    columnWidth: 24,
 
    /**
     * @cfg {Ext.XTemplate} rowBodyTpl
     * An XTemplate which, when passed a record data object, produces HTML for the expanded
     * row content.
     *
     * Note that if this plugin is applied to a lockable grid, the rowBodyTpl applies to the normal
     * (unlocked) side. See {@link #lockedTpl}
     *
     */
    rowBodyTpl: null,
 
    /**
     * @cfg {Ext.XTemplate} [lockedTpl]
     * An XTemplate which, when passed a record data object, produces HTML for the expanded
     * row content *on the locked side of a lockable grid*.
     */
    lockedTpl: null,
 
    /**
     * @cfg {Boolean} expandOnEnter
     * This config is no longer supported. The Enter key initiated the grid's actinoable mode.
     */
 
    /**
     * @cfg {Boolean} expandOnDblClick
     * `true` to toggle a row between expanded/collapsed when double clicked
     * (defaults to `true`).
     */
    expandOnDblClick: true,
 
    /**
     * @cfg {Boolean} selectRowOnExpand
     * `true` to select a row when clicking on the expander icon
     * (defaults to `false`).
     */
    selectRowOnExpand: false,
 
    /**
     * @cfg {Boolean} scrollIntoViewOnExpand
     * @since 6.2.0
     * `true` to ensure that the full row expander body is visible when clicking on the expander
     * icon (defaults to `true`)
     */
    scrollIntoViewOnExpand: true,
 
    /**
     * @cfg {Number} 
     * The width of the Row Expander column header
     */
    headerWidth: 24,
 
    /**
     * @cfg {Boolean} [bodyBefore=false]
     * Configure as `true` to put the row expander body *before* the data row.
     * 
     */
    bodyBefore: false,
 
    rowBodyTrSelector: '.' + Ext.baseCSSPrefix + 'grid-rowbody-tr',
    rowBodyHiddenCls: Ext.baseCSSPrefix + 'grid-row-body-hidden',
    rowCollapsedCls: Ext.baseCSSPrefix + 'grid-row-collapsed',
 
    addCollapsedCls: {
        fn: function(out, values, parent) {
            var me = this.rowExpander;
 
            if (!me.recordsExpanded[values.record.internalId]) {
                values.itemClasses.push(me.rowCollapsedCls);
            }
 
            this.nextTpl.applyOut(values, out, parent);
        },
 
        syncRowHeights: function(lockedItem, normalItem) {
            this.rowExpander.syncRowHeights(lockedItem, normalItem);
        },
 
        // We need a high priority to get in ahead of the outerRowTpl
        // so we can setup row data
        priority: 20000
    },
 
    /**
     * @event expandbody
     * **Fired through the grid's View**
     * @param {HTMLElement} rowNode The <tr> element which owns the expanded row.
     * @param {Ext.data.Model} record The record providing the data.
     * @param {HTMLElement} expandRow The <tr> element containing the expanded data.
     */
    /**
     * @event collapsebody
     * **Fired through the grid's View.**
     * @param {HTMLElement} rowNode The <tr> element which owns the expanded row.
     * @param {Ext.data.Model} record The record providing the data.
     * @param {HTMLElement} expandRow The <tr> element containing the expanded data.
     */
 
    setCmp: function(grid) {
        var me = this,
            features;
 
        me.callParent([grid]);
 
        // Keep track of which record internalIds are expanded.
        me.recordsExpanded = {};
 
        //<debug>
        if (!me.rowBodyTpl) {
            Ext.raise("The 'rowBodyTpl' config is required and is not defined.");
        }
        //</debug>
 
        me.rowBodyTpl = Ext.XTemplate.getTpl(me, 'rowBodyTpl');
        features = me.getFeatureConfig(grid);
 
        if (grid.features) {
            grid.features = Ext.Array.push(features, grid.features);
        }
        else {
            grid.features = features;
        }
        // NOTE: features have to be added before init (before Table.initComponent)
    },
 
    /**
     * @protected
     * @return {Array} And array of Features or Feature config objects.
     * Returns the array of Feature configurations needed to make the RowExpander work.
     * May be overridden in a subclass to modify the returned array.
     */
    getFeatureConfig: function(grid) {
        var me = this,
            features = [],
            featuresCfg = {
                ftype: 'rowbody',
                rowExpander: me,
                rowIdCls: me.rowIdCls,
                bodyBefore: me.bodyBefore,
                recordsExpanded: me.recordsExpanded,
                rowBodyHiddenCls: me.rowBodyHiddenCls,
                rowCollapsedCls: me.rowCollapsedCls,
                setupRowData: me.getRowBodyFeatureData,
                setup: me.setup
            };
 
        features.push(Ext.apply({
            lockableScope: 'normal',
            getRowBodyContents: me.getRowBodyContentsFn(me.rowBodyTpl)
        }, featuresCfg));
 
        // Locked side will need a copy to keep the two DOM structures symmetrical.
        // A lockedTpl config is available to create content in locked side.
        // The enableLocking flag is set early in Ext.panel.Table#initComponent if any columns
        // are locked.
        if (grid.enableLocking) {
            features.push(Ext.apply({
                lockableScope: 'locked',
                getRowBodyContents: me.lockedTpl
                    ? me.getRowBodyContentsFn(me.lockedTpl)
                    : function() {
                        return '';
                    }
            }, featuresCfg));
        }
 
        return features;
    },
 
    getRowBodyContentsFn: function(rowBodyTpl) {
        var me = this;
 
        return function(rowValues) {
            rowBodyTpl.owner = me;
 
            return rowBodyTpl.applyTemplate(rowValues.record.getData());
        };
    },
 
    init: function(grid) {
        var me = this,
            // Plugin attaches to topmost grid if lockable
            ownerLockable = grid.lockable && grid,
            view, lockedView, normalView;
 
        if (ownerLockable) {
            me.lockedGrid = ownerLockable.lockedGrid;
            me.normalGrid = ownerLockable.normalGrid;
            lockedView = me.lockedView = me.lockedGrid.getView();
            normalView = me.normalView = me.normalGrid.getView();
        }
 
        me.callParent([grid]);
        me.grid = grid;
        view = me.view = grid.getView();
 
        // If the owning grid is lockable, ensure the collapsed class is applied to the locked side
        // by adding a row processor to both views.
        if (ownerLockable) {
            me.bindView(lockedView);
            me.bindView(normalView);
            me.addExpander(me.lockedGrid.headerCt.items.getCount() ? me.lockedGrid : me.normalGrid);
 
            // Add row processor which adds collapsed class.
            // Ensure tpl and view can access this plugin via a "rowExpander" property.
            lockedView.addRowTpl(me.addCollapsedCls).rowExpander =
                normalView.addRowTpl(me.addCollapsedCls).rowExpander =
                lockedView.rowExpander =
                normalView.rowExpander = me;
 
            // If our client grid part of a lockable grid, we listen to its ownerLockable's
            // processcolumns
            ownerLockable.mon(ownerLockable, {
                processcolumns: me.onLockableProcessColumns,
                lockcolumn: me.onColumnLock,
                unlockcolumn: me.onColumnUnlock,
                scope: me
            });
        }
        // Add row processor which adds collapsed class
        else {
            me.bindView(view);
            // Ensure tpl and view can access this plugin
            view.addRowTpl(me.addCollapsedCls).rowExpander =
                view.rowExpander = me;
            me.addExpander(grid);
            grid.on('beforereconfigure', me.beforeReconfigure, me);
        }
    },
 
    onItemAdd: function(newRecords, startIndex, newItems) {
        var me = this,
            ownerLockable = me.grid.lockable,
            len = newItems.length,
            record,
            i;
 
        // If any added items are expanded, we will need a syncRowHeights call on next layout
        for (= 0; i < len; i++) {
            record = newRecords[i];
 
            if (!record.isNonData && me.recordsExpanded[record.internalId]) {
                if (ownerLockable) {
                    me.grid.syncRowHeightOnNextLayout = true;
                }
 
                return;
            }
        }
    },
 
    beforeReconfigure: function(grid, store, columns, oldStore, oldColumns) {
        var me = this;
 
        if (columns) {
            me.expanderColumn = new Ext.grid.column.Column(me.getHeaderConfig());
            columns.unshift(me.expanderColumn);
        }
 
    },
 
    onLockableProcessColumns: function(lockable, lockedHeaders, normalHeaders) {
        this.addExpander(lockedHeaders.length ? lockable.lockedGrid : lockable.normalGrid);
    },
 
    /**
     * @private
     * Inject the expander column into the correct grid.
     *
     * If we are expanding the normal side of a lockable grid, poke the column
     * into the locked side if the locked side has columns
     */
    addExpander: function(expanderGrid) {
        var me = this,
            selModel = expanderGrid.getSelectionModel(),
            checkBoxPosition = selModel.injectCheckbox;
 
        me.expanderColumn = expanderGrid.headerCt.insert(0, me.getHeaderConfig());
 
        // If a CheckboxModel, and it's position is 0, it must now go at position one because this
        // cell always gets in at position zero, and spans 2 columns.
        if (checkBoxPosition === 0 || checkBoxPosition === 'first') {
            checkBoxPosition = 1;
        }
 
        selModel.injectCheckbox = checkBoxPosition;
    },
 
    getRowBodyFeatureData: function(record, idx, rowValues) {
        var me = this;
 
        me.self.prototype.setupRowData.apply(me, arguments);
 
        rowValues.rowBody = me.getRowBodyContents(rowValues);
        rowValues.rowBodyCls = me.recordsExpanded[record.internalId] ? '' : me.rowBodyHiddenCls;
    },
 
    bindView: function(view) {
        var me = this,
            listeners = {
                itemkeydown: me.onKeyDown,
                scope: me
            };
 
        if (me.expandOnDblClick) {
            listeners.itemdblclick = me.onDblClick;
        }
 
        if (me.grid.lockable) {
            listeners.itemadd = me.onItemAdd;
        }
 
        view.on(listeners);
    },
 
    onKeyDown: function(view, record, row, rowIdx, e) {
        var me = this,
            key = e.getKey(),
            pos = view.getNavigationModel().getPosition(),
            isCollapsed;
 
        if (pos) {
            row = Ext.fly(row);
            isCollapsed = row.hasCls(me.rowCollapsedCls);
 
            // + key on collapsed or - key on expanded
            if (((key === 107 || (key === 187 && e.shiftKey)) && isCollapsed) ||
                ((key === 109 || key === 189) && !isCollapsed)) {
                me.toggleRow(rowIdx, record);
            }
        }
    },
 
    onDblClick: function(view, record, row, rowIdx, e) {
        this.toggleRow(rowIdx, record);
    },
 
    toggleRow: function(rowIdx, record) {
        if (record.isNonData) {
            // do not expand or collapse group headers and summaries
            return;
        }
 
        // eslint-disable-next-line vars-on-top
        var me = this,
            // If we are handling a lockable assembly,
            // handle the normal view first
            view = me.normalView || me.view,
            fireView = view,
            rowNode = view.getNode(rowIdx),
            normalRow = Ext.fly(rowNode),
            lockedRow,
            nextBd = normalRow.down(me.rowBodyTrSelector, true),
            wasCollapsed = normalRow.hasCls(me.rowCollapsedCls),
            addOrRemoveCls = wasCollapsed ? 'removeCls' : 'addCls',
            ownerLockable = me.grid.lockable && me.grid,
            expanderCell;
 
        normalRow[addOrRemoveCls](me.rowCollapsedCls);
        Ext.fly(nextBd)[addOrRemoveCls](me.rowBodyHiddenCls);
        me.recordsExpanded[record.internalId] = wasCollapsed;
 
        Ext.suspendLayouts();
 
        // Sync the collapsed/hidden classes on the locked side
        if (ownerLockable) {
            // It's the top level grid's LockingView that does the firing
            // when there's a lockable assembly involved.
            fireView = ownerLockable.getView();
 
            // Only attempt to toggle lockable side if it is visible.
            if (me.lockedGrid.isVisible()) {
 
                view = me.lockedView;
 
                // The other side must be thrown into the layout matrix so that
                // row height syncing can be done. If it is collapsed but floated,
                // it will not automatically be added to the layout when the top 
                // level grid layout calculates its layout children.
                view.lockingPartner.updateLayout();
 
                // Process the locked side.
                lockedRow = Ext.fly(view.getNode(rowIdx));
 
                // Just because the grid is locked, doesn't mean we'll necessarily have
                // a locked row.
                if (lockedRow) {
                    lockedRow[addOrRemoveCls](me.rowCollapsedCls);
 
                    // If there is a template for expander content in the locked side,
                    // toggle that side too
                    nextBd = lockedRow.down(me.rowBodyTrSelector, true);
                    Ext.fly(nextBd)[addOrRemoveCls](me.rowBodyHiddenCls);
                }
            }
 
            // We're going to need a layout run to synchronize row heights
            ownerLockable.syncRowHeightOnNextLayout = true;
        }
 
        fireView.fireEvent(wasCollapsed ? 'expandbody' : 'collapsebody', rowNode, record, nextBd);
        view.refreshSize(true);
 
        if (me.expanderColumn) {
            // rowNode is fetched again to consider the locked grid as well
            rowNode = view.getNode(rowIdx);
            expanderCell = Ext.fly(rowNode).selectNode('td.' + me.expanderColumn.tdCls);
 
            if (expanderCell) {
                expanderCell.setAttribute(
                    (Ext.isIE ? 'aria-labelledby' : 'aria-describedby'), view.id + (wasCollapsed
                        ? '-aria-description-rowexpanded'
                        : '-aria-description-rowcollapsed'));
            }
        }
 
        Ext.resumeLayouts(true);
 
        if (me.scrollIntoViewOnExpand && wasCollapsed) {
            me.grid.ensureVisible(rowIdx);
        }
    },
 
    // Called from TableLayout.finishedLayout
    syncRowHeights: function(lockedItem, normalItem) {
        var me = this,
            lockedBd = Ext.fly(lockedItem).down(me.rowBodyTrSelector),
            normalBd = Ext.fly(normalItem).down(me.rowBodyTrSelector),
            lockedHeight,
            normalHeight;
 
        // If expanded, we have to ensure expander row heights are synched
        if (normalBd.isVisible()) {
 
            // If heights are different, expand the smallest one
            if ((lockedHeight = lockedBd.getHeight()) !== (normalHeight = normalBd.getHeight())) {
                if (lockedHeight > normalHeight) {
                    normalBd.setHeight(lockedHeight);
                }
                else {
                    lockedBd.setHeight(normalHeight);
                }
            }
        }
        // When not expanded we do not control the heights
        else {
            lockedBd.dom.style.height = normalBd.dom.style.height = '';
        }
    },
 
    onColumnUnlock: function(lockable, column) {
        var me = this,
            lockedColumns;
 
        lockable = lockable || me.grid;
        lockedColumns = lockable.lockedGrid.visibleColumnManager.getColumns();
 
        // User has unlocked all columns and left only the expander column in the locked side.
        if (lockedColumns.length === 1) {
            lockable.normalGrid.removeCls(Ext.baseCSSPrefix + 'grid-hide-row-expander-spacer');
            lockable.lockedGrid.addCls(Ext.baseCSSPrefix + 'grid-hide-row-expander-spacer');
 
            if (lockedColumns[0] === me.expanderColumn) {
                lockable.unlock(me.expanderColumn);
            }
            else {
                lockable.lock(me.expanderColumn, 0);
            }
        }
    },
 
    onColumnLock: function(lockable, column) {
        var me = this,
            lockedColumns;
 
        lockable = lockable || me.grid;
        lockedColumns = me.lockedGrid.visibleColumnManager.getColumns();
 
        // This is the first column to move into the locked side.
        // The expander column must follow it.
        if (lockedColumns.length === 1) {
            me.lockedGrid.headerCt.insert(0, me.expanderColumn);
            lockable.normalGrid.addCls(Ext.baseCSSPrefix + 'grid-hide-row-expander-spacer');
            lockable.lockedGrid.removeCls(Ext.baseCSSPrefix + 'grid-hide-row-expander-spacer');
        }
    },
 
    getHeaderConfig: function() {
        var me = this,
            lockable = me.grid.lockable && me.grid;
 
        return {
            width: me.headerWidth,
            ignoreExport: true,
            lockable: false,
            autoLock: true,
            sortable: false,
            resizable: false,
            draggable: false,
            hideable: false,
            menuDisabled: true,
            tdCls: Ext.baseCSSPrefix + 'grid-cell-special',
            innerCls: Ext.baseCSSPrefix + 'grid-cell-inner-row-expander',
            renderer: function(value, metaData, record) {
                var cls = Ext.baseCSSPrefix + (record.isNonData
                    ? 'grid-row-non-expander'
                    : 'grid-row-expander');
 
                return '<div class="' + cls + '" role="presentation" tabIndex="-1"></div>';
            },
            processEvent: function(type, view, cell, rowIndex, cellIndex, e, record) {
                var isTouch = e.pointerType === 'touch',
                    isExpanderClick = !!e.getTarget('.' + Ext.baseCSSPrefix + 'grid-row-expander');
 
                if ((type === "click" && isExpanderClick) ||
                    (type === 'keydown' && e.getKey() === e.SPACE)) {
 
                    // Focus the cell on real touch tap.
                    // This is because the toggleRow saves and restores focus
                    // which may be elsewhere than clicked on causing a scroll jump.
                    if (isTouch) {
                        cell.focus();
                    }
 
                    me.toggleRow(rowIndex, record, e);
                    e.stopSelection = !me.selectRowOnExpand;
                }
                else if (e.type === 'mousedown' && !isTouch && isExpanderClick) {
                    e.preventDefault();
                }
            },
 
            // This column always migrates to the locked side if the locked side is visible.
            // It has to report this correctly so that editors can position things correctly
            isLocked: function() {
                return lockable && (lockable.lockedGrid.isVisible() || this.locked);
            },
 
            // In an editor, this shows nothing.
            editRenderer: function() {
                return '&#160;';
            }
        };
    }
});