/*
 * 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 {EntityFamily} from '../util/model/entity.model';
import template from './catalog-selector.template.html';
import footerTemplate from './catalog-selector-palette-footer.html';
import { distanceInWordsToNow } from 'date-fns';

const MODULE_NAME = 'brooklyn.composer.component.catalog-selector';
const TEMPLATE_URL = 'blueprint-composer/component/catalog-selector/index.html';
const TEMPLATE_SUBHEAD_URL = 'blueprint-composer/component/catalog-selector/subhead.html';
const TEMPLATE_FOOTER_URL = 'blueprint-composer/component/catalog-selector/footer.html';
const MIN_ROWS_PER_PAGE = 4;

const PALETTE_VIEW_ORDERS = {
        relevance: { id: "relevance", label: "Relevance", field: "relevance" },
        name: { id: "name", label: "Name", field: "displayName" },
        lastUsed: { id: "lastUsed", label: "Recent", field: "-lastUsed" }, 
        bundle: { id: "bundle", label: "Bundle", field: "containingBundle" }, 
        id: { id: "id", label: "ID", field: "symbolicName" }, 
    };

const PALETTE_VIEW_MODES = {
        tiny: { name: "Tiny", classes: "col-xs-2 item-compact", itemsPerRow: 6, rowHeightPx: 75, hideName: true },
        compact: { name: "Compact", classes: "col-xs-3", itemsPerRow: 4 },
        normal: { name: "Normal", classes: "col-xs-4", itemsPerRow: 3 },
        large: { name: "Large", classes: "col-xs-6", itemsPerRow: 2 },
        list: { name: "List", classes: "col-xs-12 item-full-width", itemsPerRow: 1 },
        compactList: { name: "Compact list", classes: "col-xs-12 item-compact-list", itemsPerRow: 1, rowHeightPx: 30 },
    };

// fields in either bundle or type record:
const FIELDS_TO_SEARCH = ['displayName', 'name', 'symbolicName', 'type', 'version', 'containingBundle', 'description', 'displayTags', 'tags', 'supertypes'];

angular.module(MODULE_NAME, [])
    .directive('catalogSelector', catalogSelectorDirective)
    .filter('catalogSelectorSearch', catalogSelectorSearchFilter)
    .filter('catalogSelectorFilters', catalogSelectorFiltersFilter)
    .run(['$templateCache', templateCache]);

export default MODULE_NAME;

export function catalogSelectorDirective() {
    return {
        restrict: 'E',
        templateUrl: function (tElement, tAttrs) {
            return tAttrs.templateUrl || TEMPLATE_URL;
        },
        scope: {
            family: '<',
            onSelect: '&', // action to do when item is selected
            onSelectText: "&?", // function returning text to show in the "on select" button for an item
            iconSelects: '<?',  // boolean whether clicking the icon triggers selection directly or shows popup (false, default) 
            rowsPerPage: '<?',  // optionally show fixed number of rows; unset (default and normal) computes based on available height
            reservedKeys: '<?',
            state: '<?', // for shared state usage
            mode: '@?',  // for use by downstream projects to pass in special modes to do add'l processing / rendering
        },
        controller: ['$scope', '$element', '$timeout', '$q', '$uibModal', '$log', '$templateCache', 'paletteApi', 'paletteDragAndDropService', 'iconGenerator', 'composerOverrides', 'recentlyUsedService', controller],
        link: link,
    };

    function link($scope, $element, attrs, controller) {
        let main = angular.element($element[0].querySelector(".catalog-palette-main"));

        // repaginate when load completes (and items are shown), or it is resized
        $scope.$watchGroup(
            [() => $scope.isLoading, () => main[0].offsetHeight, () => $scope.state.viewMode.name],
            (values) => controller.$timeout(() => repaginate($scope, $element)));
        // also repaginate on window resize
        angular.element(window).bind('resize', () => repaginate($scope, $element));

        $scope.templateUrls = {
            subhead: TEMPLATE_SUBHEAD_URL,
            footer: TEMPLATE_FOOTER_URL
        }
    }

    function controller($scope, $element, $timeout, $q, $uibModal, $log, $templateCache, paletteApi, paletteDragAndDropService, iconGenerator, composerOverrides, recentlyUsedService) {
        this.$timeout = $timeout;

        $scope.viewModes = PALETTE_VIEW_MODES;
        $scope.viewOrders = PALETTE_VIEW_ORDERS;
        if (!$scope.state) $scope.state = {};
        if (!$scope.state.viewMode) $scope.state.viewMode = PALETTE_VIEW_MODES.normal;

        $scope.pagination = {
            page: 1,
            itemsPerPage: $scope.state.viewMode.itemsPerRow * ($scope.rowsPerPage || 1)  // will fill out after load
        };

        $scope.getEntityNameForPalette = function(item, entityName) {
            return (composerOverrides.getEntityNameForPalette ||
                // above can be overridden with function of signature below to customize display name in palette
                function(item, entityName, scope) { return entityName; }
            )(item, entityName, $scope);
        };

        $scope.getPlaceHolder = function () {
            return 'Search';
        };

        $scope.isLoading = true;

        $scope.$watch('search', () => {
            $scope.freeFormTile = {
                symbolicName: $scope.search,
                name: $scope.search,
                displayName: $scope.search,
                supertypes: ($scope.family ? [ $scope.family.superType ] : []),
            };
        });

        $scope.getItems = function (search) {
            let defer = $q.resolve([]);

            switch ($scope.family) {
                case EntityFamily.ENTITY:
                case EntityFamily.SPEC:
                    defer = paletteApi.getTypes({params: {supertype: 'entity', fragment: search}});
                    break;
                case EntityFamily.POLICY:
                    defer = paletteApi.getTypes({params: {supertype: 'policy', fragment: search}});
                    break;
                case EntityFamily.ENRICHER:
                    defer = paletteApi.getTypes({params: {supertype: 'enricher', fragment: search}});
                    break;
                case EntityFamily.LOCATION:
                    defer = paletteApi.getLocations();
                    break;
            }

            return defer.then(data => {
                data = $scope.filterPaletteItemsForMode(data, $scope);
                data.forEach( recentlyUsedService.embellish );
                return data;

            }).catch(error => {
                return [];
            }).finally(() => {
                $scope.isLoading = false;
            });
        };
        function tryMarkUsed(item) {
            try {
                recentlyUsedService.markUsed(item);
            } catch (e) {
                // session storage can get full; usually the culprit is icons not this,
                // but we may wish to clear out old items to ensure we don't bleed here
                $log.warn("Could not mark item as used: "+item, e);
            }
        }
        $scope.mouseInfoPopover = (item, enter) => {
            if ($scope.popoverModal && $scope.popoverVisible && $scope.popover==item) {
                // ignore if modal
                return;
            }
            $scope.popoverModal = false;
            if (enter) {
                $scope.popover = item;
                $scope.popoverVisible = true;
            } else {
                $scope.popoverVisible = false;
            }
        };
        $scope.onClickItem = (item, isInfoIcon, $event) => {
            if (!isInfoIcon && $scope.iconSelects) {
                $scope.onSelectItem(item);
            } else if ($scope.popoverModal && $scope.popoverVisible && $scope.popover == item) {
                $scope.closePopover();
            } else {
                $scope.popover = item;
                $scope.popoverVisible = true;
                $scope.popoverModal = true;
            }
            $event.stopPropagation();
        };
        $scope.closePopover = () => {
            $scope.popoverVisible = false;
            $scope.popoverModal = false;
        };
        $scope.getOnSelectText = function (item) {
            if (!($scope.onSelectText)) return "Select";
            return $scope.onSelectText({item: item});
        };
        $scope.onSelectItem = function (item) {
            $scope.closePopover();
            if (angular.isFunction($scope.onSelect)) {
                tryMarkUsed(item);
                $scope.onSelect({item: item});
            }
            $scope.search = '';
        };
        $scope.onDragItem = function (item, event) {
            let frame = document.createElement('div');
            frame.classList.add('drag-frame');
            event.target.appendChild(frame);
            setTimeout(function() {
                // can remove at end of this cycle, browser will have grabbed its drag image
                frame.parentNode.removeChild(frame);
            }, 0);
            /* have tried many other ways to get a nice drag image;
               this seems to work best, adding an empty div which forces the size to be larger,
               so when grabbing the image it grabs the drop-shadow.
               things that _didn't_ work include:
               - styling event.target now then unstyling (normally this would work, in posts on the web, but it doesn't here; angular?)
               - make a restyled cloned copy offscreen (this comes so close but remote img srcs aren't loaded
             */

            paletteDragAndDropService.dragStart(item);
        };
        $scope.onDragEnd = function (item, event) {
            paletteDragAndDropService.dragEnd();
            tryMarkUsed(item);
        };

        $scope.getOpenCatalogLink = (item) => {
            return "/brooklyn-ui-catalog/#!/bundles/"+item.containingBundle.replace(":","/")+"/types/"+item.symbolicName+"/"+item.version;
        };
        $scope.sortBy = function (order) {
            let newFirst = {};
            if (order) {
                newFirst[order.id] = order;
            }
            $scope.state.currentOrder = Object.assign(newFirst, $scope.state.currentOrder, newFirst);
            $scope.state.currentOrderFields = [];
            $scope.state.currentOrderValues = [];
            Object.values($scope.state.currentOrder).forEach( it => {
                $scope.state.currentOrderValues.push(it);
                $scope.state.currentOrderFields.push(it.field);
            });
        };
        if (!$scope.state.currentOrder) $scope.state.currentOrder = Object.assign({}, PALETTE_VIEW_ORDERS);
        $scope.sortBy();

        $scope.allowFreeForm = function () {
            return [
                EntityFamily.LOCATION
            ].indexOf($scope.family) > -1;
        };
        $scope.isReserved = function () {
            if (!$scope.reservedKeys || !angular.isArray($scope.reservedKeys)) {
                return false;
            }
            return $scope.reservedKeys.indexOf($scope.search) > -1;
        };
        $scope.onImageError = (scope, el, attrs) => {
            $log.warn("Icon for "+attrs.itemId+" at "+angular.element(el).attr("src")+" could not be loaded; generating icon");
            angular.element(el).attr("src", iconGenerator(attrs.itemId));
        };

        // Init
        $scope.items = [];
        function getDisplayTags(tags) {
            if (!tags || !tags.length || !tags.reduce) return tags;
            return tags.reduce((result, tag) => {
                if (!(/[=:\[\]()]/.exec(tag))) {
                    result.push(tag);
                }
                return result;
            }, []);
        }
        $scope.getItems().then((items)=> {
            // add displayTags, as any tag that doesn't contain = : or ( ) [ ]
            // any tag that is an object will be eliminated as it is toStringed to make [ object object ]
            items.forEach(item => {
                if (item.tags) {
                    item.displayTags = getDisplayTags(item.tags);
                }
            });
            $scope.items = items;
        });
        $scope.lastUsedText = (item) => {
            if (item==null) return "";
            let l = (Number)(item.lastUsed);
            if (!l || isNaN(l) || l<=0) return "";
            if (l < 100000) return 'Preselected for inclusion in "Recent" filter.';
            return 'Last used: ' + distanceInWordsToNow(l, { includeSeconds: true, addSuffix: true });
        };

        $scope.showPaletteControls = false;
        $scope.onFiltersShown = () => {
            $timeout( () => {
                // check do we need to show the multiline
                let filters = angular.element($element[0].querySelector(".filters"));
                $scope.$apply( () => $scope.filterSettings.filtersMultilineAvailable = filters[0].scrollHeight > filters[0].offsetHeight + 6 );

                repaginate($scope, $element);
            } );
        };
        $scope.togglePaletteControls = () => {
            $scope.showPaletteControls = !$scope.showPaletteControls;
            $timeout( () => repaginate($scope, $element) );
        };
        $scope.toggleShowAllFilters = () => {
            $scope.filterSettings.showAllFilters = !$scope.filterSettings.showAllFilters;
            $timeout( () => repaginate($scope, $element) );
        };
        $scope.filterSettings = {};

        $scope.filters = [
            { label: 'Recent', icon: 'clock-o', title: "Recently used and standard favorites", limitToOnePage: true,
                filterInit: items => {
                    $scope.recentItems = items.filter( i => i.lastUsed && i.lastUsed>0 );
                    $scope.recentItems.sort( (a,b) => b.lastUsed - a.lastUsed );
                    return $scope.recentItems;
                }, enabled: false },
        ];
        $scope.disableFilters = (showFilters) => {
            $scope.filters.forEach( f => f.enabled = false );
            if (showFilters !== false) {
                $scope.showPaletteControls = true;
            }
        };

        // can be overridden to disable "open in catalog" button
        $scope.allowOpenInCatalog = true;

        // this can be overridden for palette sections/modes which show a subset of the types returned by the server;
        // this is applied when the data is received from the server.
        // it is used by catalogSelectorFiltersFilter;
        $scope.filterPaletteItemsForMode = (items) => items;

        // allow downstream to configure this controller and/or scope
        (composerOverrides.configurePaletteController || function() {})(this, $scope, $element);
    }

    function repaginate($scope, $element) {
        let rowsPerPage = $scope.rowsPerPage;
        if (!rowsPerPage) {
            let palette = angular.element(document.querySelector(".page-main-area"));
            let toolbar = angular.element(document.querySelector(".navbar-mode"));
            let header = angular.element($element[0].querySelector(".catalog-palette-header"));
            let footer = angular.element($element[0].querySelector(".catalog-palette-footer"));
            rowsPerPage = Math.max(MIN_ROWS_PER_PAGE, Math.floor((palette[0].offsetHeight - (toolbar[0].offsetHeight + header[0].offsetHeight + footer[0].offsetHeight + 16)) / ($scope.state.viewMode.rowHeightPx || 96)));
        }
        $scope.$apply(() => $scope.pagination.itemsPerPage = rowsPerPage * $scope.state.viewMode.itemsPerRow);
    }
}

export function catalogSelectorSearchFilter() {
    return function (items, search) {
        if (search) {
            return items.filter(function (item) {
                item.relevance = 0;
                let wordNum = 0;
                return search.toLowerCase().split(' ').reduce( (found, part) => {
                    wordNum++;
                    let fieldNum = 0;
                    return found &&
                        FIELDS_TO_SEARCH.reduce((match, field) => {
                            if (match) return true;
                            fieldNum++;
                            if (!item.hasOwnProperty(field) || !item[field]) return false;
                            let text = item[field];
                            if (!text.toLowerCase) {
                                text = JSON.stringify(text).toLowerCase();
                            } else {
                                text = text.toLowerCase();
                            }
                            let index = text.indexOf(part);
                            if (index == -1) return false;
                            // found, set relevance -- uses an ad hoc heuristic preferring first fields and short text length,
                            // earlier occurrences and earlier words weighted more highly (smaller number is better)
                            let score = fieldNum * (2 / (1 + wordNum)) * Math.log(1 + text.length * index);
                            /* to debug the scoring function:
                            if (item.symbolicName.indexOf("EIP") >= 0 || item.symbolicName.indexOf("OpsWorks") >= 0) { 
                                console.log(item.symbolicName, ": match", part, "in", field,
                                    "#", fieldNum, wordNum, 
                                    "pos", index, "/", text.length, 
                                    ":", item.relevance, "+=", score);
                            }
                            */
                            item.relevance += score;
                            return true;
                        }, false)
                }, true);
            });
        } else {
            items.forEach( item => item.relevance = 0 );
            return items;
        }
    }
}

export function catalogSelectorFiltersFilter() {
    // compute counts and apply active filters;     
    // this is called by the view after filtering based on search,
    // so filters can adjust based on number of search results
    return function (items, $scope) {
      $scope.itemsBeforeActiveFilters = items;
      $scope.skippingFilters = false; 
      let filters = $scope.filters.filter(f => f.enabled);
      let filtersWithFn = filters.filter(f => f.filterFn);
      if (!filters.length) {
        $scope.itemsAfterActiveFilters = items;
        return items;
      }
      filters.forEach(filter => { if (filter.filterInit) items = filter.filterInit(items); });
      if (filtersWithFn.length) {
        items = items.filter( item => filtersWithFn.some(filter => filter.filterFn(item)) );
      }
      if (!items || !items.length) {
        // if search matches nothing then disable filters
        items = $scope.itemsAfterActiveFilters = $scope.itemsBeforeActiveFilters;
        $scope.skippingFilters = true;
      } else {
        if (filters.find(filter => filter.limitToOnePage)) {
            items = items.splice(0, $scope.pagination.itemsPerPage);
        }  
        $scope.itemsAfterActiveFilters = items;
      }
      return items; 

    }
}

function templateCache($templateCache) {
    $templateCache.put(TEMPLATE_URL, template);
    $templateCache.put(TEMPLATE_SUBHEAD_URL, '');
    $templateCache.put(TEMPLATE_FOOTER_URL, footerTemplate);
}
