/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you under the Apache License, Version 2.0 (the
 * "License"); you may not use this file except in compliance
 * with the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 * KIND, either express or implied.  See the License for the
 * specific language governing permissions and limitations
 * under the License.
 */
import angular from 'angular';
import jssha from 'jssha';
import brUtils from '../utils/general';
import 'angular-multiple-transclusion';
import template from './index.html';

const TEMPLATE_CONTAINER_URL = 'br/template/table/table.html';
const MODULE_NAME = 'brooklyn.components.table';

angular.module(MODULE_NAME, ['angular-multiple-transclusion', brUtils])
    .directive('brTable', ['$log', brTableDirective])
    .directive('bindHtmlCompile', ['$compile', brBindHtmlCompile])
    .filter('deepFilter', ['$filter', '$parse', brDeepFilter])
    .run(['$templateCache', brTableRun]);

export default MODULE_NAME;

/**
 * BASIC
 *
 * <br-table ng-model="data" columns="columns">
 * 
 * where for example `data = [ { name: "Babs", age: 20 }, { name: "Bob", age: 21 } ]`
 * and `columns = [ { field: 'name' }, { header: 'Age', template: '{{ item.age }} years old' } ]`
 *
 *
 * COMPLETE
 *
 * <br-table
 *   ng-model="..." column="..."     // required, as above
 *   row-ui-state="app" row-ui-state-params="getUiStateParams"   // ui-router state to redirect on row click, with function giving parameters
 *   col-width="100"    // minimum size in px to require for underlying <table> columns (unless overridden) 
 *   >
 *
 * Each column map entry may also include:
 *   field, // optional, if supplied it provides a default for header, template, orderBy, and id
 *   header, // required, unless field is specified in which case it provides a default value (with "camelCaseEXAMPLE" rendered as "Camel Case EXAMPLE")
 *   template, templateUrl,  // exactly one of these is required, unless field is specified in which case `{{ item[field] }}` is a default value
 *   orderBy: 'name', // a field on the object to use for sorting; if omitted, and field is not set, the column is not sortable
 *   id: 'name', // a unique ID for the column, used for column-specific searching, and set as a class for styling;
 *                  this ID is used in regexes so should not contain spaces or regex special characters (`.` or `(`);
 *                  if omitted, `field` is used if specified, otherwise a `col-N` identifier is used (and column-specific searches are not offered as suggestions)
 *   tdClass: 'fancy-column', // a string to set as a class on the <td> items in this column; if omitted and `id` is explicit, that is used as a default; failing that no column-specific classes are set
 *   colspan: 3, // number of <table> columns to use for this logical column - useful to control relative widths as the <table> columns are all the same width
 *   width: 100, // width of the column in px; if used with colspan this applies to _one_ column and the colspan-1 others are an additional relative width
 *   hidden: false, // whether column is initially hidden
 *
 *
 * WIDTHS
 *
 * The combo of colspan and width allows fine control over column widths, e.g.:
 *
 * [ { header: "A", width: 200, colspan: 2 }, { header: "B", colspan: 3 } ]
 * will set up a table with 5 <table> columns, with A spanning 2 and B spanning 3.
 * One of A's columns is fixed at 200px; the other 4 columns share equally the remaining width.
 * So the table's minimum width is 200px, and at that size B will not be visible.
 * At 300px, there is 100px "remaining" split among the four regular columns, so
 * A gets 225px (a 200px column plus one regular column) and B gets 75x (three regular columns).
 * At 600px, the two logical columns - A and B - are equally sized at 300px.
 * Beyond this, column B is wider. 
 *
 * If additionally `col-width="100"` is set on the `br-table`, then the table will start
 * with minimum width 600px (possibly requiring horizontal scrolling to see in a small browser).
 * Individual columns can be resized by a user subsequently.    
 * 
 * For convenience (and compatibility for tables before colspan support was added)
 * if no column entries define a colspan, the table layout is auto,
 * with any explicit `width` being respected and other columns based on content width.
 *
 *
 * SEARCH
 *
 * When searching, phrases in double quotes "like this" search for that group of words with no spaces 
 * using angular case-insensitive containment (non-regex).
 * Any remaining term of the form `key:value` where `key` is one of the column's `id`
 * will do a regex search for that value in the respective column.
 * All other terms are searched for as individual words using angular case-insensitive containment (non-regex).
 * The angular (non-regex) search treats a leading `!` as negation, to search for entries _not_ containing the term.
 */
export function brTableDirective($log) {
    return {
        require: 'ngModel',
        transclude: true,
        restrict: 'E',
        link: link,
        controller: ['$templateCache', 'brUtilsGeneral', controller],
        controllerAs: 'ctrl',
        scope: true,
        templateUrl: function(element, attrs) {
            return attrs.templateUrl || TEMPLATE_CONTAINER_URL;
        }
    };

    function link(scope, element, attrs, ngModelCtrl) {
        scope.ctrl.rowUiState = attrs.rowUiState;
        scope.ctrl.rowUiStateParams = scope.$eval(attrs.rowUiStateParams);
        scope.ctrl.state = {
            columns: scope.$eval(attrs.columns),
            sorts: [],
            search: '',
            filters: {}
        };

        if (!(scope.ctrl.state.columns instanceof Array)) {
            throw new Error('Field "columns" in table options must be of type "Array"');
        }
        scope.ctrl.state.columnSpanCount = 0;
        scope.ctrl.state.columnExplicitWidthCount = 0;
        scope.ctrl.state.columnExplicitWidthSum = 0;
        scope.ctrl.tableLayoutFixed = false;
        scope.ctrl.state.columns.forEach((column, index) => {
            if (!(column instanceof Object)) {
                throw new Error(`Column with index "${index}" must be of type "Object"`);
            }
            let field = column.field;
            if (!column.hasOwnProperty('header')) {
                if (field) {
                    column.header = field.replace(/([a-z])([A-Z])/g, (_, a, A) => a+' '+A).replace(/^([a-z])(.*)/, (_, a, bc) => a.toUpperCase()+bc)
                } else {
                    throw new Error(`Column with index ${index} does not has the required field "header"`);
                }
            }
            if (!column.hasOwnProperty('template') && !column.hasOwnProperty('templateUrl')) {
                if (field) {
                    column.template = `{{ item['${field}'] }}`;
                } else {
                    throw new Error(`Column with index ${index} requires either "template" or "templateUrl" field`);
                }
            }
            if (!column.hasOwnProperty('id')) {
                if (field) {
                    column.id = field;
                } else {
                    column.id = 'col-'+index;
                    column.idAutogenerated = true;
                }
            } else {
                column.tdClass = column.tdClass || column.id;
            }
            if (!column.hasOwnProperty('orderBy') && field) {
                column.orderBy = field;
            }

            column.hidden = column.hidden || false;

            column.regex = new RegExp(`(?:\\s|^)${column.id}:(\\S*)(?:\\s|$)`, 'i')
            
            if (!column.idAutogenerated) {
              column.idForTypeahead = column.id;
            }
        });
        
        function recomputeSpanCount() {
            // this should be recomputed when columns are hidden/shown
            // without this, the "No results" message may be slightly too wide when columns are hidden
            
            var columnSpanCount = 0,
                columnExplicitWidthCount = 0,
                columnExplicitWidthSum = 0,
                tableLayoutFixed = false;
            
            scope.ctrl.state.columns.forEach((column, index) => {
                if (column.hidden) return;
                columnSpanCount += (column.colspan || 1);
                tableLayoutFixed |= column.colspan;
                if (column.width) {
                    columnExplicitWidthCount ++;
                    columnExplicitWidthSum += column.width;
                }
            });
            
            scope.ctrl.state.columnSpanCount = columnSpanCount;
            scope.ctrl.state.columnExplicitWidthCount = columnExplicitWidthCount;
            scope.ctrl.state.columnExplicitWidthSum = columnExplicitWidthSum;
            scope.ctrl.tableLayoutFixed = tableLayoutFixed;
            
            if (attrs.colWidth) {
                var minWidth = (scope.ctrl.state.columnExplicitWidthSum + 
                    (scope.ctrl.state.columnSpanCount - scope.ctrl.state.columnExplicitWidthCount) * attrs.colWidth);
                if (isNaN(parseFloat(minWidth)) || !isFinite(minWidth)) {
                    // not a valid number in the end: could install units-css library and do unit maths, but not worth it
                    $log.error(`Error computing column widths (got ${scope.ctrl.minWidth}): ensure no values have units`);
                } else {
                    scope.ctrl.minWidth = minWidth + 'px';
                }
            }
        }
        
        recomputeSpanCount();
        
        scope.hideColumn = (column,) => {
            column.hidden = !column.hidden;
            recomputeSpanCount();
        };

        let sha = new jssha('SHA-512', 'TEXT');
        sha.update(scope.ctrl.rowUiState || '');
        sha.update(JSON.stringify(scope.ctrl.rowUiStateParams) || '');
        sha.update(JSON.stringify(scope.ctrl.state.columns) || '');
        let hash = sha.getHash('HEX');

        if (sessionStorage) {
            let state = sessionStorage.getItem(`${MODULE_NAME}.state.${hash}`);
            if (state !== null) {
                scope.ctrl.state = Object.assign(scope.ctrl.state, JSON.parse(state));
                scope.ctrl.state.columns.forEach(column => column.regex = new RegExp(`(?:\\s|^)${column.id}:(\\S*)(?:\\s|$)`, 'i'));
            }

            scope.$watch('ctrl.state', (newValue, oldValue) => {
                if (!angular.equals(newValue, oldValue)) {
                    sessionStorage.setItem(`${MODULE_NAME}.state.${hash}`, JSON.stringify(newValue));
                }
            }, true);
        }

        scope.$watchCollection('ctrl.items', function(value) {
            ngModelCtrl.$setViewValue(value);
            ngModelCtrl.$validate();
        });

        ngModelCtrl.$render = function() {
            if (!ngModelCtrl.$viewValue) {
                ngModelCtrl.$viewValue = [];
            }
            scope.ctrl.items = ngModelCtrl.$viewValue;
        };

        scope.$applyAsync(() => {
            element[0].querySelectorAll('th div').forEach(elm => {
                angular.element(elm).data('initialWidth', elm.offsetWidth);
            });
            element[0].querySelectorAll('span.column-resizer').forEach(elm => {
                elm.ondragstart = function() { return false; };
                elm.addEventListener('mousedown', function(e) {
                    if (e.which === 1) {
                        // left mouse click
                        scope.ctrl.dragStart(e);
                    }
                }, false);
            });
        });

        scope.$watch('ctrl.state.search', (newValue, oldValue) => {
            if (newValue === oldValue) {
                return;
            }
            let filters = {};
            let remaining = newValue;
            let words = [];
            // get any phrases in double quotes
            let qw = /(?:\s|^)"([^"]*)"(?:\s|$)/;
            var match;
            while (match=qw.exec(remaining)) {
                words.push(match[1]);
                remaining = remaining.replace(qw, ' ');
            }
            // now get anything that matches column prefix
            scope.ctrl.state.columns.forEach(column => {
                if (column.hidden) {
                    return;
                }
                let matches = remaining.match(column.regex);
                if (matches === null) {
                    return;
                }
                filters[column.id] = matches[1];
                remaining = remaining.replace(column.regex, ' ');
            });
            // remaining items are split
            remaining = remaining.trim();
            if (remaining.length > 0) {
                words = words.concat(remaining.split(/\s+/));
            }
            words = words.length==0 ? null : words.length==1 ? words[0] : words;
            if (Object.keys(filters).length > 0) {
                if (words) {
                    filters[''] = words;
                }
            }
            scope.ctrl.state.filters = Object.keys(filters).length > 0 ? filters : words;
        });
    }

    function findAncestor($el, tag) {
        while ($el[0] && $el[0].tagName.toUpperCase() != tag.toUpperCase()) { 
                $el = $el.parent(); 
        };
        return $el;
    }

    function controller($templateCache, brUtilsGeneral) {
        this.getColumnTemplate = (id) => {
            let column = this.state.columns.find(column => column.id === id);
            return column.hasOwnProperty('templateUrl') ? $templateCache.get(column.templateUrl) : column.template;
        };

        this.dragStart = (e) => {
            this.resizerTarget = angular.element(e.target);
            this.thTarget = findAncestor(this.resizerTarget, 'th');
            this.tableTarget = findAncestor(this.thTarget, 'table');
            if (!this.tableTarget[0]) throw new Error('Resizer tag hierarchy not as expected; cannot drag');
            
            this.tableWidth = this.tableTarget[0].offsetWidth;
            if (this.tableTarget[0].style.minWidth != 0) {
                // if a width is defined, we need to hardcode all column widths
                // this.tableTarget.find('colgroup').children().forEach(
                angular.forEach( this.tableTarget.find('thead').find("tr").children(), th => { th.width = th.offsetWidth; } );
                angular.forEach( this.tableTarget.find('colgroup').children(), col => { col.style.width = null; } );
                this.tableTarget[0].style.minWidth = 0;
            }
            this.width = this.thTarget[0].offsetWidth;
            this.start = e.clientX;

            document.addEventListener('mouseup', this.dragEnd, false);
            document.addEventListener('mousemove', this.dragging, false);

            this.resizerTarget.addClass('dragging');
            
            // Disable highlighting while dragging
            if (e.stopPropagation) e.stopPropagation();
            if (e.preventDefault) e.preventDefault();
        };
        this.dragging = (e) => {
            // 23px wide is bare minimum given padding settings
            // if user goes below this we could give some visual indication the column is being hidden 
            var newWidth = Math.max(this.width - this.start + e.clientX, 23);  
            this.thTarget[0].style['width'] = `${newWidth}px`;
            this.tableTarget[0].style['width'] = `${this.tableWidth + newWidth - this.width}px`;
        };
        this.dragEnd = (e) => {
            document.removeEventListener('mouseup', this.dragEnd, false);
            document.removeEventListener('mousemove', this.dragging, false);
            this.resizerTarget.removeClass('dragging');
        };

        this.sortBy = (orderBy) => {
            let sort = '+';
            let currentOrderBy = this.getSortByFrom(orderBy);
            let currentOrderByIndex = this.state.sorts.indexOf(currentOrderBy);

            if (currentOrderBy) {
                let currentSort = this.getSortByDirectionFrom(orderBy);
                if (currentSort === '-') {
                    this.state.sorts.splice(currentOrderByIndex, 1);
                    return;
                }
                if (currentSort === '+') {
                    sort = '-';
                }
                this.state.sorts.splice(currentOrderByIndex, 1, `${sort}${orderBy}`);
            } else {
                this.state.sorts.push(`${sort}${orderBy}`);
            }
        };

        this.getSortByFrom = (orderBy) => {
            return this.state.sorts.find(sort => sort.substr(1) === orderBy);
        };

        this.getSortByDirectionFrom = (orderBy) => {
            let sortBy = this.getSortByFrom(orderBy);
            return sortBy ? sortBy.slice(0, 1) : '';
        };

        this.suggestionFormatter = (input) => {
            return brUtilsGeneral.isNonEmpty(input) ? `${input}:` : '';
        };
    }
}

export function brBindHtmlCompile($compile) {
    return {
        restrict: 'A',
        link: function (scope, element, attrs) {
            scope.$watch(function () {
                return scope.$eval(attrs.bindHtmlCompile);
            }, function (value) {
                element.html(value);
                $compile(element.contents())(scope);
            });
        }
    };
}

export function brDeepFilter($filter, $parse) {
    return function(input, opts) {
        if (angular.isString(opts)) {
            return $filter('filter')(input, opts);
        }
        if (angular.isArray(opts)) {
            opts = { '': opts };
        }
        if (angular.isObject(opts) && Object.keys(opts).length > 0) {
            return input.filter(item => {
                return Object.keys(opts).reduce((ret, key) => {
                    if (!ret) return false;
                    if (key == '') {
                        // empty key used for text search
                        let searchList = opts[key];
                        if (!angular.isArray(searchList)) searchList = [ searchList ];
                        return searchList.every( word => $filter('filter')([ item ], word).length );
                    }
                    let value = $parse(key)(item);
                    if (!value) return false;
                    let vv = JSON.stringify(value);
                    if (vv[0]=='"') vv = vv.slice(1, vv.length - 1);
                    return new RegExp(opts[key], 'ig').test(vv);
                }, true);
            });
        }
        return input;
    };
}

export function brTableRun($templateCache) {
    $templateCache.put(TEMPLATE_CONTAINER_URL, template);
}

