/*
 * 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 onEnter from 'brooklyn-ui-utils/on-enter/index';
import autoGrow from 'brooklyn-ui-utils/autogrow/index';
import blurOnEnter from 'brooklyn-ui-utils/blur-on-enter/index';
import {EntityFamily, baseType} from '../util/model/entity.model';
import {Dsl} from '../util/model/dsl.model';
import {Issue, ISSUE_LEVEL} from '../util/model/issue.model';
import {RESERVED_KEYS, DSL_ENTITY_SPEC} from '../providers/blueprint-service.provider';
import brooklynDslEditor from '../dsl-editor/dsl-editor';
import brooklynDslViewer from '../dsl-viewer/dsl-viewer';
import template from './spec-editor.template.html';
import {graphicalState} from '../../views/main/graphical/graphical.state';

const MODULE_NAME = 'brooklyn.components.spec-editor';
const ANY_MEMBERSPEC_REGEX = /(^.*[m,M]ember[s,S]pec$)/;
const REPLACED_DSL_ENTITYSPEC = '___brooklyn:entitySpec';

angular.module(MODULE_NAME, [onEnter, autoGrow, blurOnEnter, brooklynDslEditor, brooklynDslViewer])
    .directive('specEditor', ['$rootScope', '$templateCache', '$injector', '$sanitize', '$filter', '$log', '$sce', '$timeout', '$document', '$state', '$compile', 'blueprintService', 'composerOverrides', specEditorDirective])
    .filter('specEditorConfig', specEditorConfigFilter)
    .filter('specEditorType', specEditorTypeFilter)
    .run(['$templateCache', templateCache]);

const TEMPLATE_URL = 'blueprint-composer/component/spec-editor/spec-editor.template.html';

export default MODULE_NAME;

export const CONFIG_FILTERS = [
    {
        id: 'suggested',
        label: 'Suggested',
        icon: 'plus-circle',
        hoverText: 'Show config keys that marked as pinned or priority',
        filter: (item)=> {
            return item.pinned && item.priority > -1;
        }
    },
    {
        id: 'required',
        label: 'Required',
        icon: 'stop-circle',
        hoverText: 'Show config keys that are required or have an issue',
        filter: (item, model)=> {
            return (item.constraints && item.constraints.required) ||
                (model && model.issues && model.issues.some((issue)=>(issue.group === 'config' && issue.ref === item.name)) );
        }
    },
    {
        id: 'inuse',
        label: 'In Use',
        icon: 'check-circle',
        hoverText: 'Show config keys that are in use',
        filter: (item, model)=> {
            return model && model.config && model.config.has(item.name);
        }
    },
    {
        id: 'all',
        label: 'All',
        icon: 'th-list',
        hoverText: 'Show all config keys',
        filter: (item)=> {
            return item;
        }
    }
];

export const PARAM_TYPES = [
    // cut-down list from BasicSpecParameter.ParseYamlInputs
    'string', 'boolean', 'integer', 'double', 'duration', ' port'
    ];

export function specEditorDirective($rootScope, $templateCache, $injector, $sanitize, $filter, $log, $sce, $timeout, $document, $state, $compile, blueprintService, composerOverrides) {
    return {
        restrict: 'E',
        scope: {
            model: '='
        },
        controller: ['$scope', '$element', controller],
        templateUrl: function (tElement, tAttrs) {
            return tAttrs.templateUrl || TEMPLATE_URL;
        },
        link: link,
        controllerAs: 'specEditor',
    };

    function controller($scope, $element) {
        (composerOverrides.configureSpecEditorController || function () {
        })(this, $scope, $element);

        // does very little currently, but link adds to this
        return this;
    }

    function link(scope, element, attrs, specEditor) {
        scope.specEditor = specEditor;
        scope.FAMILIES = EntityFamily;
        scope.RESERVED_KEYS = RESERVED_KEYS;
        scope.REPLACED_DSL_ENTITYSPEC = REPLACED_DSL_ENTITYSPEC;
        scope.parameters = [];
        scope.config = {};
        specEditor.paramTypes = PARAM_TYPES;

        specEditor.getParameter = getParameter;
        specEditor.addParameter = addParameter;
        specEditor.removeParameter = removeParameter;

        let defaultState = {
            parameters: {
                add: {
                    value: '',
                    open: false
                },
                edit: {
                    value: '',
                    name: '',
                    open: false,
                },
                search: '',
                focus: '',
                filter: {
                    open: false,
                },
                open: false,

                codeModeActive: {},
                codeModeError: {},
            },
            config: {
                add: {
                    value: '',
                    open: false
                },
                search: '',
                focus: '',
                filter: {
                    values: { suggested: true, required: true, inuse: true },
                    open: false,
                },
                // TODO would be nice to set null here, then have it go true if there is filtered config
                // but the collapsible widget doesn't seem to respond to subsequent state changed
                open: true,

                dslViewerManualOverride: {},
                codeModeActive: {},
                codeModeForced: {},
                codeModeError: {},
                customConfigWidgetMetadata: {},
            },
            location: {
                open: false
            },
            policy: {
                search: '',
                open: false
            },
            enricher: {
                search: '',
                open: false
            }
        };

        // allow downstream to configure this controller and/or scope
        (composerOverrides.configureSpecEditor || function () {
        })(specEditor, scope, element, $state, $compile, $templateCache);

        scope.filters = {
            config: CONFIG_FILTERS,
        };
        scope.isFilterDisabled = (filter) => filter.id !== 'all' && scope.state.config.filter.values['all'];
        scope.onFilterClicked = (filter) => {
            if (!scope.isFilterDisabled(filter)) scope.state.config.filter.values[filter.id] = !scope.state.config.filter.values[filter.id];
        };

        scope.state = sessionStorage && sessionStorage.getItem(scope.model._id)
            ? JSON.parse(sessionStorage.getItem(scope.model._id))
            : defaultState;
        scope.$watch('state', () => {
            if (sessionStorage) {
                try {
                    sessionStorage.setItem(scope.model._id, JSON.stringify(scope.state));
                } catch (ex) {
                    $log.error('Cannot save edit state: ' + ex.message);
                }
            }
        }, true);
        scope.$watch('state.config.filter.values', () => {
            scope.state.config.add.list = getAddListConfig();
        });

        loadCustomConfigWidgetMetadata(scope);

        // Model
        scope.$watch('model', (newVal, oldVal) => {
            if (newVal && !newVal.equals(oldVal)) {
                loadLocalConfigFromModel();
                loadLocalParametersFromModel();
            }
        }, true);
        scope.$watch('model.id', () => {
            blueprintService.refreshAllRelationships();
        });

        scope.$watch('state.parameters', (newVal, oldVal) => {
            setModelFromLocalParameters();
        }, true);

        // Parameters
        scope.$watch('parameters', (newVal, oldVal) => {
            setModelFromLocalParameters();
            scope.model.clearIssues({group: 'parameters'});
        }, true);

        // Config
        scope.$watch('config', (newVal, oldVal) => {
            setModelFromLocalConfig();
            scope.model.clearIssues({group: 'config'});
            blueprintService.refreshRelationships(scope.model).then(() => {
                return blueprintService.refreshConfigConstraints(scope.model);
            }).then(() => {
                refreshCustomValidation(scope.model);
            });
        }, true);

        scope.getObjectSize = (object) => {
            return specEditor.defined(object) && object !== null ? Object.keys(object).length : 0;
        };

        function findNext(element, clazz, stopClazz) {
            let el = element, last = null;
            while (last !== el) {
                last = el;
                if (el.children().length) {
                    el = angular.element(el.children()[0]);
                } else {
                    while (el.length && !el.next().length) {
                        el = el.parent();
                    }
                    el = el.next();
                }
                if (el.hasClass(clazz)) {
                    return el;
                }
                if (stopClazz && el.hasClass(stopClazz)) break;
            }
            return null;
        }

        function findPrev(element, clazz, stopClazz) {
            let el = element, last = null;
            while (last !== el) {
                last = el;
                if (el.children().length) {
                    // this still does children first, not ideal but okay for how we use it
                    el = angular.element(el.children()[el.children().length - 1]);
                } else {
                    while (el.length && !el[0].previousElementSibling) {
                        el = el.parent();
                    }
                    el = angular.element(el[0].previousElementSibling);
                }
                if (el.hasClass(clazz)) {
                    return el;
                }
                if (stopClazz && el.hasClass(stopClazz)) break;
            }
            return null;
        }

        function findAncestor(element, clazz, stopClazz) {
            let el = element, last = null;
            while (last !== el) {
                last = el;
                el = el.parent();
                if (el.hasClass(clazz)) {
                    return el;
                }
                if (stopClazz && el.hasClass(stopClazz)) return null;
            }
            return null;
        }

        function focusIfPossible(event, next) {
            if (next && next.length) {
                if (event) event.preventDefault();
                if (event) event.stopPropagation();
                next[0].focus();
                return true;
            } else {
                return false;
            }
        }

        scope.nonempty = (o) => o && Object.keys(o).length;
        scope.defined = specEditor.defined = (o) => (typeof o !== 'undefined');
        specEditor.isInstance = (x, type) => (typeof x === type);
                
        specEditor.advanceOutToFormGroupInPanel = (element, event) => {
            focusIfPossible(event, findAncestor(element, "form-group", "panel-body")) || element[0].blur();
        };
        specEditor.advanceControlInFormGroup = (element, event) => {
            focusIfPossible(event, findNext(element, "form-control", "form-group")) ||
            specEditor.advanceOutToFormGroupInPanel(element, event);
        };

        scope.onAddMapProperty = (configKey, key, ev) => {
            if (key) {
                if (!scope.config[configKey]) {
                    scope.config[configKey] = {};
                }
                if (!scope.config[configKey].hasOwnProperty(key)) {
                    scope.config[configKey][key] = '';

                    // defer so control exists, then focus on previous item (new control)
                    $timeout(() => {
                        let element = angular.element(ev.target);
                        let prev = findPrev(element, "form-control", "form-group");
                        focusIfPossible(null, prev) ||
                        specEditor.advanceOutToFormGroupInPanel(element, null);
                    }, 0);
                } else {
                    // user entered a key that already exists;
                    // would be nice to select the item they requested but with just jqlite that's more pain than it's worth!
                }
            }
        };
        scope.cycleExpandMode = specEditor.cycleExpandMode = function (expandMode, ctx, item, focus) {
            return expandMode === 'default' ? 'open' :
                expandMode === 'open' ? 'closed' :
                    'default';
        };
        scope.onDeleteMapProperty = specEditor.onDeleteMapProperty = function (model, key) {
            if (model && model.hasOwnProperty(key)) {
                delete model[key];
            }
        };
        scope.onAddListItem = specEditor.onAddListItem = (configKey, item, ev) => {
            if (item) {
                if (!scope.config[configKey]) {
                    scope.config[configKey] = [];
                }
                if (scope.config[configKey].indexOf(item) === -1) {
                    scope.config[configKey].push(item);
                }
                ev.target.focus();
            }
        };
        scope.onDeleteListItem = specEditor.onDeleteListItem = function (model, index) {
            if (model && index < model.length) {
                model.splice(index, 1);
            }
        };
        scope.isConfigHidden = specEditor.isConfigHidden = (config) => {
            let allConfig = scope.model.miscData.get('config');
            if (allConfig.indexOf(config) === -1) {
                return false;
            }
            return $filter('specEditorConfig')(allConfig, scope.state.config.filter.values).indexOf(config) === -1;
        };
        scope.onFocusOnConfig = specEditor.onFocusOnConfig = ($item) => {
            scope.state.config.search = '';
            scope.state.config.add.value = '';
            scope.state.config.add.open = false;
            scope.state.config.focus = $item.name;
            if ($item.isHidden) {
                scope.state.config.filter.values.all = true;
            }
        };
        scope.onFocusOnParameter = specEditor.onFocusOnParameter = ($item) => {
            scope.state.parameters.search = '';
            scope.state.parameters.add.value = '';
            scope.state.parameters.add.open = false;
            scope.state.parameters.focus = $item.name;
        };
        scope.recordConfigFocus = specEditor.recordConfigFocus = ($item) => {
            scope.state.config.focus = $item.name;
        };
        scope.recordParameterFocus = specEditor.recordParameterFocus = ($item) => {
            scope.state.parameters.focus = $item.name;
            scope.state.parameters.edit = {
                item: $item,
                newName: $item.name,
                constraints: $item.constraints ? JSON.stringify($item.constraints) : '',
                original: Object.assign({}, $item),
                json: JSON.stringify($item, null, "  "),
            };
        };

        scope.removeAdjunct = ($event, adjunct) => {
            $event.preventDefault();
            $event.stopPropagation();
            switch (adjunct.family) {
                case EntityFamily.POLICY:
                    scope.model.removePolicy(adjunct._id);
                    break;
                case EntityFamily.ENRICHER:
                    scope.model.removeEnricher(adjunct._id);
                    break;
            }
        };

        scope.removeModel = () => {
            switch (scope.model.family) {
                case EntityFamily.ENRICHER:
                    scope.model.parent.removeEnricher(scope.model._id);
                    break;
                case EntityFamily.POLICY:
                    scope.model.parent.removePolicy(scope.model._id);
                    break;
                default:
                    $rootScope.$broadcast('d3.remove', scope.model);
                    break;
            }
            $state.go(graphicalState.name);
        };

        specEditor.getParameterIssues = () => {
            return scope.model.issues
                .filter((issue) => (issue.group === 'parameters'))
                .concat(Object.values(scope.model.getClusterMemberspecEntities())
                    .filter((spec) => (spec && spec.hasIssues()))
                    .reduce((acc, spec) => (acc.concat(spec.issues)), []));
        };
        scope.getConfigIssues = specEditor.getConfigIssues = () => {
            return scope.model.issues
                .filter((issue) => (issue.group === 'config'))
                .concat(Object.values(scope.model.getClusterMemberspecEntities())
                    .filter((spec) => (spec && spec.hasIssues()))
                    .reduce((acc, spec) => (acc.concat(spec.issues)), []));
        };
        scope.getPoliciesIssues = () => {
            return scope.model.getPoliciesAsArray().reduce((acc, policy) => {
                if (policy.hasIssues()) {
                    acc = acc.concat(policy.issues)
                }
                return acc;
            }, []);
        };
        scope.getEnrichersIssues = () => {
            return scope.model.getEnrichersAsArray().reduce((acc, enricher) => {
                if (enricher.hasIssues()) {
                    acc = acc.concat(enricher.issues)
                }
                return acc;
            }, []);
        };

        scope.getBadgeClass = (issues) => {
            return issues.some(issue => issue.level === ISSUE_LEVEL.ERROR) ? 'badge-danger' : 'badge-warning';
        };

        specEditor.descriptionHtml = (text) => {
            let out = [];
            for (let item of text.split(/\n\n+/)) {
                out.push('<div class="paragraph-spacing"></div>');
                out.push($sanitize(item));
            }
            out.splice(0, 1);
            return $sce.trustAsHtml(out.join("\n"));
        };

        function getConfigWidgetModeInternal(item, val) {
            if (angular.element($document[0].activeElement).hasClass("form-control") && item.widgetMode) {
                // don't switch mode in mid-edit, e.g. if you are manually typing $brooklyn:component("x").config("y")
                // don't interrupt when the user has typed $brooklyn:component("x")!
                return item.widgetMode;
            }

            if (specEditor.isDsl(item.name)) {
                return scope.state.config.dslViewerManualOverride && scope.state.config.dslViewerManualOverride[item.name] ?
                    "dsl-manual" // causes default string editor
                    : "dsl-viewer";
            }

            if (!specEditor.defined(val)) val = scope.config[item.name];
            let type = baseType(item.type);

            // if actual value's type does not match declared type,
            // e.g. object is a map when declared type is object or string or something else,
            // we could render e.g. w map editor as a convenience;
            // but for now the logic is only based on the declared type

            if (type === 'java.lang.Boolean') type = 'boolean';
            else if (type === 'java.util.Map') type = 'map';
            else if (type === 'java.util.Set' || type === 'java.util.List' || type === 'java.util.Collection') type = 'array';

            if (specEditor.defined(val)) {
                // override to use string editor if the editor doesn't support the value
                // (probably this is an error, though type-coercion might make it not so)
                if (type === 'boolean') {
                    if (typeof val !== type) return type + '-manual';  // causes default string editor
                } else if (type === 'map') {
                    if (typeof val !== 'object') return type + '-manual';  // causes default string editor
                } else if (type === 'array') {
                    if (!Array.isArray(val)) return type + '-manual';  // causes default string editor
                }
            }
            if (scope.state.config.codeModeActive[item.name]) {
                // code mode forces manual editor
                return type + '-manual';
            }

            return type;
        }
        scope.getConfigWidgetMode = (item, value) => {
            // record the value as `item.widgetMode` so we can reference it subsequently, as well as returning it

            if (item.type) {
                return item.widgetMode = getConfigWidgetModeInternal(item, value);
            }

            // if type isn't set then infer
            if (value instanceof Array) {
                item.widgetMode = 'array';
            } else if (value instanceof Object) {
                item.widgetMode = 'map';
            } else {
                item.widgetMode = 'unknown';
            }
            return item.widgetMode;
        };
        specEditor.getParameterWidgetMode = (item) => {
            let type = item.type || item.typeName;

            if (type === 'java.lang.Boolean') type = 'boolean';
            else if (type === 'java.util.Map') type = 'map';
            else if (type === 'java.util.Set' || type === 'java.util.List' || type === 'java.util.Collection' || type.startsWith('List<')) type = 'array';

            return type;
        };
        scope.isCodeModeAvailable = (item) => {
            let val = scope.config[item.name];

            if (scope.state.config.codeModeActive[item.name]) {
                // simple check to update whether it is forced when active -- if it starts with { or [
                scope.state.config.codeModeForced[item.name] = (typeof val === 'string' && val.trim().match(/^[{\[]/));
                // always available if user has set it active, and poor-man's forced check above should apply
                return true;
            }
            // else don't force unless the parse confirms it
            scope.state.config.codeModeForced[item.name] = false;
            // otherwise true if the text entered is json
            if (!specEditor.defined(val)) return false;
            if (typeof val === 'string') {
                try {
                    // a YAML parse would be nicer, but JSON is simpler, and sufficient
                    // (esp as we export a JSON stringify; preferable would be to export the exact YAML from model,
                    // including comments, but again lower priority)
                    let parsed = JSON.parse(val);
                    // if parse returns a string or a complex object, then it looks like JSON was entered
                    // (e.g. "foo" or {a:1}); if not, eg user entered boolean or number (45 or true),
                    // then don't offer code mode; if it was complex, additionally force code mode
                    scope.state.config.codeModeForced[item.name] = typeof parsed === 'object' || typeof parsed === 'array';
                    return (scope.state.config.codeModeForced[item.name] || typeof parsed === 'string');
                } catch (ex) {
                    // not valid text input for code mode
                    // if "codeModeForced" with poor-man's check was enabled we should disable that
                    scope.state.config.codeModeForced[item.name] = false;
                    return false;
                }
            }
            if (val instanceof Dsl) return false;
            if (val instanceof Array || val instanceof Object) return true;
            // other primitive
            return false;
        };
        scope.codeModeClick = (item) => {
            if (scope.state.config.codeModeForced[item.name] && scope.state.config.codeModeActive[item.name]) {
                // if forced and active, don't allow clicks
                return;
            }
            let oldMode = !!(scope.state.config.codeModeActive[item.name]);
            // convert local config from json to non-json or vice-versa
            let value = scope.config[item.name];
            if (!specEditor.defined(value)) {
                value = null;
            }
            if (value !== null) {
                if (oldMode) {
                    // leaving code mode
                    try {
                        // if it's a parseable string, then parse it
                        if (typeof value === 'string' && value.length) {
                            value = JSON.parse(value);
                            if (value instanceof Array || value instanceof Object) {
                                // if result is not a primitive then don't allow user to leave code mode
                                // (if type is known as a map/list then likely we should allow leaving code mode,
                                // if we check and confirm that all entries are json primitives, but that can be left as a TODO;
                                // only would enter that state if user clicked to enter code for a map/list,
                                // and they can reset by switching to yaml)
                                $log.warn("JSON code is not a primitive; leaving as code mode:", item.name, value);
                                return;
                            }
                            // parsed value is a primitive, make that be the value shown
                        } else {
                            // if type is not a string, then show unchanged value
                            // (would only come here on weird changes)
                            value = null;
                        }
                    } catch (notJson) {
                        // if not parseable then leave alone
                        value = null;
                    }
                } else {
                    // entering code mode
                    try {
                        if (typeof value === 'string') {
                            if (value.length) {
                                JSON.parse(value);
                                // if it parses, treat as a json object
                            } else {
                                // if empty, leave empty
                            }
                        } else {
                            // if not a string then treat as not parseable
                            throw 'not json';
                        }
                    } catch (notJson) {
                        // if not parseable or not a string, then convert to json
                        value = JSON.stringify(value);
                    }
                }
                if (value !== null) {
                    scope.config[item.name] = value;
                }
            }
            scope.state.config.codeModeActive[item.name] = !oldMode;
            if (value !== null) {
                // local config changed, make sure model is updated too
                setModelFromLocalConfig();
            }
        };
        scope.getJsonModeTitle = (itemName) => {
            if (!scope.state.config.codeModeActive[itemName]) {
                return "Treat this value as a JSON-encoded object [" + itemName + "]";
            }
            if (scope.state.config.codeModeForced[itemName]) {
                return "This data is a complex object and can only be entered as JSON [" + itemName + "]";
            } else {
                return "Edit in simple mode, unwrapping JSON if possible [" + itemName + "]";
            }
        };
        scope.parameterCodeModeClick = (item) => {
            let oldMode = !!(item && scope.state.parameters.codeModeActive[item.name]);
            let value = null;
            if (oldMode) {
                // leaving code mode
                value = scope.state.parameters.edit.json;
                try {
                    value = JSON.parse(value);
                    let i = scope.parameters.findIndex(p => p.name === item.name);
                    scope.parameters[i] = value;
                } catch (notJson) {
                    // if not parseable then leave alone
                    value = null;
                }
            } else {
                // entering code mode
                // leave code mode for other parameter if set
                if (scope.state.parameters.edit.newName && scope.state.parameters.edit.newName!=item.name) {
                    scope.parameterCodeModeClick(getParameter(scope.state.parameters.edit.newName));
                }
                // convert local parameter from json to non-json or vice-versa
                value = item;
                if (value !== null) {
                    try {
                        JSON.parse(value);
                    } catch (notJson) {
                        // if not parseable or not a string, then convert to json
                        // (as with other stringify, this loses any comments etc)
                        value = JSON.stringify(value, null, "  ");
                    }
                    if (value !== null) {
                        scope.state.parameters.edit.json = value;
                    }
                }
            }
            scope.state.parameters.codeModeActive[item.name] = !oldMode;
            if (value !== null) {
                // local parameters changed, make sure model is updated too
                setModelFromLocalParameters();
            }
        };
        /** returns 'enabled' or 'disabled' if a widget is defined, or null if no special widget is defined */
        specEditor.getCustomConfigWidgetMode = (item) => {
            var widgetMetadata = scope.state.config.customConfigWidgetMetadata[item.name];
            if (!widgetMetadata) return null;
            if (widgetMetadata["error"]) {
                return "disabled";
            }
            return widgetMetadata["enabled"] ? 'enabled' : 'disabled';
        };
        specEditor.customConfigWidgetError = (item) => {
            var widgetMetadata = scope.state.config.customConfigWidgetMetadata[item.name];
            if (!widgetMetadata || !widgetMetadata["error"]) return null;
            if (widgetMetadata.manualToggleAfterError && widgetMetadata.enabled) {
                // show the error if manually enabled
                return widgetMetadata["error"];
            }
            return null;
        };
        specEditor.customParameterErrors = (item) => {
            if (scope.state.parameters && scope.state.parameters.edit && scope.state.parameters.edit.item === item && scope.state.parameters.edit.errors) {
                return scope.state.parameters.edit.errors;
            }
            return [];
        };
        specEditor.toggleCustomConfigWidgetMode = (item, newval) => {
            let widgetMetadata = scope.state.config.customConfigWidgetMetadata[item.name];
            if (!widgetMetadata) {
                $log.error('Custom widget mode should not be toggled when not available: ' + item.name);
                return null;
            }
            if (!specEditor.defined(newval)) {
                if (widgetMetadata["error"] && !widgetMetadata.manualToggleAfterError) {
                    widgetMetadata.manualToggleAfterError = true;
                    newval = true;
                } else {
                    newval = !widgetMetadata.enabled;
                }
            }
            widgetMetadata.enabled = newval;
        };
        specEditor.getCustomConfigWidgetModeTitle = (item) => {
            let widgetMetadata = scope.state.config.customConfigWidgetMetadata[item.name];
            if (!widgetMetadata) {
                // shouldn't be visible
                return "(custom widget not available)";
            }
            return widgetMetadata.enabled ? "Use standard widget" : "Use custom widget";
        };
        specEditor.getCustomConfigWidgetTemplate = (item) => {
            let widgetMetadata = scope.state.config.customConfigWidgetMetadata[item.name];
            let widgetName = $sanitize(widgetMetadata.widget || '--no-widget--');
            let templateName = 'custom-config-widget-' + widgetName;
            if (!$templateCache.get(templateName)) {
                let widgetDirective = widgetName.replace(/(-[a-z])/g, function ($1) {
                    return $1[1].toUpperCase();
                }) + 'Directive';
                if ($injector.has(widgetDirective)) {
                    $templateCache.put(templateName, '<' + widgetName + ' item="item" params="state.config.customConfigWidgetMetadata[item.name]" config="config" model="model"/>');
                } else {
                    $log.error('Missing directive ' + widgetDirective + ' for custom widget for ' + item.name + '; falling back to default widget');
                    scope.state.config.customConfigWidgetMetadata[item.name].error = "Missing directive";
                    templateName = "error-" + templateName;
                    $templateCache.put(templateName, '<i>Widget ' + widgetName + ' missing</i>');
                }
            }
            return templateName;
        };
        specEditor.isDsl = (key, index) => {
            let val = scope.model.config.get(key);
            if (specEditor.defined(val) && specEditor.defined(index) && index !== null) val = val[index];
            return specEditor.isDslVal(val);
        };
        specEditor.isDslVal = (val) => {
            // don't treat constants as DSL (not helpful, and the DSL editor doesn't support it)
            return (val instanceof Dsl) && val.kind && val.kind.family !== 'constant';
        };
        specEditor.isDslWizardButtonAllowed = (key, index, nonModelValue) => {
            let val = scope.model.config.get(key);
            if (specEditor.defined(val) && specEditor.defined(index) && index !== null) val = val[index];
            if (!specEditor.defined(val) || val === null || val === '') val = nonModelValue;
            if (!specEditor.defined(val) || val === null || val === '') return true;
            if (specEditor.isDslVal(val)) {
                return true;
            }
            // non-DSL values cannot be opened in DSL editor
            return false;
        };

        function getAddListConfig() {
            if (!angular.isArray(scope.model.miscData.get('config'))) {
                return [];
            }
            let filteredConfig = $filter('specEditorConfig')(scope.model.miscData.get('config'), scope.state.config.filter.values, scope.model);
            return scope.model.miscData.get('config').map((config) => {
                config.isHidden = scope.model.miscData.get('config').indexOf(config) > -1 ? filteredConfig.indexOf(config) === -1 : false;
                return config;
            });
        }
        specEditor.getConfig = getConfig;
        function getConfig(name) {
            return scope.model.miscData.get('config').find(p => p.name === name);
        }
        

        function loadCustomConfigWidgetMetadata(model) {
            let customConfigWidgets = (scope.model.miscData.get('ui-composer-hints') || {})['config-widgets'] || [];
            customConfigWidgets.forEach((wd) => {
                let keys = wd.keys || [wd.key];
                keys.forEach((k) => {
                    scope.state.config.customConfigWidgetMetadata[k] = angular.extend({enabled: true}, scope.state.config.customConfigWidgetMetadata[k], wd);
                });
            });
        }

        /**
         * The configuration data for each item is stored in multiple places:
         *
         * scope.config
         *   A map of values used/set by editor, which can be strings, a map of strings, the JSON representation of the
         *   object if using code mode, etc. This should be suitable for ng-model to work with, so e.g. if using code
         *   mode we need to put JSON.stringify value in here, and note any change here immediately (on next digest)
         *   updates scope.model.config, which e.g. in code mode will JSON.parse
         *
         * scope.model.config
         *   A map of values used in internal model
         *
         * scope.model.miscData.get('config')
         *   A list of config keys with their metadata, including derived widgetMode
         *
         * scope.state.config.{codeModeActive,dslManualOverride}
         *   Maps of booleans where edit modes are set and remembered for configs
         */

        function loadLocalConfigFromModel() {
            let modelConfig = scope.model.config;
            let result = {};
            for (let [key, value] of modelConfig) {
                result[key] = getLocalConfigValueFromModelValue(key, value);
            }
            scope.config = result;
        }

        function getLocalConfigValueFromModelValue(key, value) {
            if (!specEditor.defined(value) || value === null) {
                return value;
            }

            if (ANY_MEMBERSPEC_REGEX.test(key) && value.hasOwnProperty(DSL_ENTITY_SPEC)) {
                let wrapper = {};
                wrapper[REPLACED_DSL_ENTITYSPEC] = value[DSL_ENTITY_SPEC];
                return wrapper;
            }
            if (value instanceof Dsl) {
                return value.toString();
            }

            let definition = scope.model.miscData.get('config').find(config => config.name === key);
            if (!definition) {
                definition = {};
                scope.getConfigWidgetMode(definition, value)
            } else {
                if (!definition.widgetMode) scope.getConfigWidgetMode(definition);
            }

            // if json mode, then just stringify
            if (scope.state.config.codeModeActive[key]) {
                if (scope.state.config.codeModeError[key]) {
                    // errors don't get stringified because model value was not parsed
                    return value;
                }
                // if current local value gives same result then don't change it
                // (don't interrupt user's flow, and respect their spacing, at least until
                // they click away; ultimately we would like access to the parse tree,
                // so we could take the text as entered exactly (maybe stripping initial whitespace),
                // also supporting yaml and comments, but that is a bigger task!)
                if (scope.config && typeof scope.config[key] === 'string') {
                    try {
                        if (JSON.stringify(JSON.parse(scope.config[key])) === JSON.stringify(value)) {
                            return scope.config[key];
                        }
                    } catch (ignoredError) {
                        $log.debug("Couldn't handle entered JSON", scope.config[key], ignoredError);
                    }
                }
                // otherwise pretty print it, so they get decent multiline on first load and
                // if they click off the entity then back on to the entity and this field
                // (we'd like to respect what they actually typed but that needs the parse tree, as above)
                return JSON.stringify(value, null, "  ");
            }

            // else treat as value, with array/map special

            try {
                if (definition.widgetMode === 'array') {
                    return value.map(item => {
                        if (item instanceof Dsl) {
                            return item.toString();
                        } else if (item instanceof Array || item instanceof Object) {
                            throw 'not simple json in array';
                        } else {
                            return item;
                        }
                    });
                } else if (definition.widgetMode === 'map') {
                    let object = {};
                    for (let keyObject in value) {
                        if (value[keyObject] instanceof Dsl) {
                            object[keyObject] = value[keyObject].toString();
                        } else if (value[keyObject] instanceof Array || value[keyObject] instanceof Object) {
                            throw 'not simple json in map';
                        } else {
                            object[keyObject] = value[keyObject];
                        }
                    }
                    return object;
                } else if (!(value instanceof Dsl) && (value instanceof Object || value instanceof Array)) {
                    throw 'must use code editor';
                }
            } catch (hasComplexJson) {
                // any map/array with complex json inside, or other value of complex json,
                // will force the code editor
                // (previously we did stringify on entries then tried to parse, but that was inconsistent)
                scope.state.config.codeModeActive[key] = true;
                // and the widget mode updated to be 'manual'
                scope.getConfigWidgetMode(definition, value);
                // and rerun this method so we get it prettified if appropriate
                return getLocalConfigValueFromModelValue(key, value);
            }

            // if boolean, return as primitive type
            if (typeof value === 'boolean') {
                return value;
            }

            // all other primitives treat as string (as they will get a string-based widget)
            return "" + value;
        }

        function getModelValueFromString(val) {
            if (!specEditor.defined(val) || val === null || typeof val !== 'string') {
                // only strings will have primitive inference applied
                // (and this is only invoked when not in code mode)
                return val;
            }
            try {
                let prim = JSON.parse(val);
                if (prim instanceof Object || prim instanceof Array || typeof prim === 'string') {
                    throw 'not primitive';
                }
                if (val.match(/^[0-9]*\.([0-9]*0)?$/)) {
                    // if user enters "3.0" or "1." we should treat it as a string to prevent
                    // yaml editor from simplifying it to be "3" or "1".
                    throw 'decimal ending with dot or 0, eg version, treat as string';
                }
                // numbers and booleans are unquoted
                return prim;
            } catch (ex) {
                return val;
            }
        }

        function setModelFromLocalConfig() {
            let localConfig = scope.config;
            let result = {};
            for (let keyRef in localConfig) {
                if (angular.isUndefined(localConfig[keyRef]) || localConfig[keyRef] === null || localConfig[keyRef].length < 1) {
                    continue;
                }
                if (ANY_MEMBERSPEC_REGEX.test(keyRef) && localConfig[keyRef].hasOwnProperty(REPLACED_DSL_ENTITYSPEC)) {
                    result[keyRef] = {};
                    result[keyRef][DSL_ENTITY_SPEC] = localConfig[keyRef][REPLACED_DSL_ENTITYSPEC];
                    continue;
                }

                let definition = scope.model.miscData.get('config').find(config => config.name === keyRef);
                if (!definition) {
                    definition = {};
                    scope.getConfigWidgetMode(definition, localConfig[keyRef])
                }

                // if JSON mode then parse
                scope.state.config.codeModeError[keyRef] = null;
                if (scope.state.config.codeModeActive && scope.state.config.codeModeActive[keyRef]) {
                    try {
                        result[keyRef] = JSON.parse(localConfig[keyRef]);
                    } catch (ex) {
                        scope.state.config.codeModeError[keyRef] = "Invalid JSON";
                        result[keyRef] = localConfig[keyRef];
                    }
                    continue;
                }

                // else return as is, or introspect for array/map

                if (definition.widgetMode === 'array') {
                    result[keyRef] = localConfig[keyRef].map(getModelValueFromString);
                    continue;
                }
                if (definition.widgetMode === 'map') {
                    result[keyRef] = {};
                    for (let keyObject in localConfig[keyRef]) {
                        result[keyRef][keyObject] = getModelValueFromString(localConfig[keyRef][keyObject]);
                    }
                    continue;
                }

                result[keyRef] = getModelValueFromString(localConfig[keyRef]);
            }

            scope.model.setConfigFromJson(result);
        }

        specEditor.addConfigKey = addConfigKey;
        function addConfigKey(name) {
            if (name) {
                let allConfig = scope.model.miscData.get('config');
                blueprintService.addConfigKeyDefinition(allConfig, name);
                scope.model.addConfig(name, '');
                loadLocalConfigFromModel();
                scope.state.config.add.value = '';
                scope.state.config.add.open = false;
                scope.state.config.filter.values[CONFIG_FILTERS[CONFIG_FILTERS.length - 1].id] = true;
                scope.state.config.focus = name;
            }
        }

        function refreshCustomValidation(model) {
            model.config.forEach((value, key) => {
                let config = model.miscData.get('config').find(config => config.name === key);
                // Ideally code below would escaped or use template/directive; sanitize just catches attacks.
                // (These values are automatically sanitized in any case, so that's redundant.)

                // If we need an integer, check if the value is a number
                if (config.type === 'java.lang.Integer' && !angular.isNumber(value) && !(value instanceof Dsl)) {
                    model.addIssue(Issue.builder().group('config').ref(key).message('<code>' + $sanitize(value) + '</code> is not a number').build());
                }
                if (scope.state.config.codeModeError[key]) {
                    model.addIssue(Issue.builder().group('config').ref(key).message('<code>' + $sanitize(value) + '</code> is not valid JSON').build());
                }
            });
        }

        /**
         * The parameter data for each item is stored in multiple places, similar to configuration data:
         *
         * scope.parameters
         *   An array of values used/set by editor
         *
         * scope.model.parameters
         *   An array of values used in internal model
         *
         * scope.state.parameters.codeModeActive
         *   A map of booleans indicating whether to edit the parameter definition as JSON
         */

        function loadLocalParametersFromModel() {
            let modelParams = scope.model.parameters;
            let result = [];
            let dups = {};
            for (let paramRef of modelParams) {
                if (dups[paramRef.name]) {
                    var i=2;
                    while (dups[paramRef.name+''+i]) i++;
                    // users won't see this message (unless they have the console open) so it might be surprising
                    // but other behaviours (like the UI breaking) are worse, and not sure of a simple better way to handle 
                    $log.warn("Duplicate parameter '"+paramRef.name+"' found; changing to '"+paramRef.name+''+i+"'");
                    paramRef.name = paramRef.name+''+i;
                }
                dups[paramRef.name] = true;
                result.push(paramRef);
            }
            scope.parameters = result;
        }

        function getParameter(name) {
            return scope.parameters.find(p => p.name === name);
        }

        function checkNameChange(oldName, newName) {
            if (!oldName) {
                return true;
            }
            if (oldName && oldName!==newName) {
                scope.state.parameters.codeModeActive[newName] = scope.state.parameters.codeModeActive[oldName];
                scope.state.parameters.codeModeError[newName] = scope.state.parameters.codeModeError[oldName]; 
                delete scope.state.parameters.codeModeActive[oldName]; 
                delete scope.state.parameters.codeModeError[oldName];
                return true;
            }
            return false;            
        }
        function setModelFromLocalParameters() {
            if (scope.state.parameters && scope.state.parameters.edit && scope.state.parameters.edit.item) {
                let item = scope.state.parameters.edit.item;
                scope.state.parameters.edit.errors = [];
                if (scope.state.parameters.codeModeActive[item.name]) {
                    try {
                        let parsed = JSON.parse(scope.state.parameters.edit.json);
                        checkNameChange(item.name, parsed.name);
                        if (JSON.stringify(item)==JSON.stringify(parsed)) {
                            // no change; don't change else we get a digest cycle
                        } else {
                            Object.keys(item).forEach((k) => delete item[k]);
                            Object.assign(item, parsed);
                        }
                        
                    } catch (e) {
                        // $log.warn("ERROR parsing json", scope.state.parameters.edit.json, e);
                        scope.state.parameters.edit.errors.push({ message: "Invalid JSON for parameter" });
                    }
                    
                } else {
                    if (scope.state.parameters.edit.newName) {
                        if (blueprintService.isReservedKey(scope.state.parameters.edit.newName)) {
                            scope.state.parameters.edit.errors.push({ message: "Illegal key name" });
                        } else if (checkNameChange(item.name, scope.state.parameters.edit.newName)) {
                            item.name = scope.state.parameters.edit.newName;
                        }
                    } else {
                        scope.state.parameters.edit.errors.push({ message: "Key name must not be blank" });
                    }
                     
                    try {
                        let c = scope.state.parameters.edit.constraints ? JSON.parse(scope.state.parameters.edit.constraints) : [];
                        if (Array.isArray(c)) {
                            if (c.length==0) { 
                                delete item['constraints'];
                            } else {
                                item.constraints = c;
                            }
                        } else {
                            scope.state.parameters.edit.errors.push({ message: "Constraint JSON must be a list" });
                        }
                    } catch (e) {
                        $log.warn("ERROR parsing constraints", scope.state.parameters.edit.constraints, e);
                        scope.state.parameters.edit.errors.push({ message: "Invalid constraint JSON" });
                    }
                    
                    // empty values are removed
                    if (item.description === '') {
                        delete item['description'];
                    } 
                    if (item.label === '') {
                        delete item['label'];
                    } 
                    if (item.default === '') {
                        if (scope.state.parameters.edit.original.default!=='') {
                            // don't delete if default was explicitly set in yaml as "";
                            // this allows empty string defaults to be used (although you can't set them in the visual ui)
                            delete item['default'];
                        }
                    }
                }
            }
            
            let localParams = scope.parameters;
            let result = [];
            for (let paramRef of localParams) {
                if (angular.isUndefined(paramRef) || paramRef === null || paramRef.length < 1) {
                    continue;
                }
                result.push(paramRef);
            }
            scope.model.setParametersFromJson(result);
        }

        function addParameter(name) {
            let allParams = scope.model.miscData.get('parameters');
            if (!allParams) {
                allParams = [];
                scope.model.miscData.set('parameters', allParams);
            }
            blueprintService.addParameterDefinition(allParams, name);
            let param = allParams.find(p => p.name === name);
            scope.model.addParameter(param);
            loadLocalParametersFromModel();
            scope.state.parameters.search = '';
            scope.state.parameters.add.value = '';
            scope.state.parameters.add.open = false;
            scope.state.parameters.focus = name;
        }

        function removeParameter(name) {
            scope.model.removeParameter(name);
            loadLocalParametersFromModel();
            if (scope.state.parameters.focus === name) {
                scope.state.parameters.focus = '';
            }
        }
    }
}

export function specEditorConfigFilter() {
    return function (input, filtersMapById, model) {
        let filters = [];
        Object.keys(filtersMapById).forEach( (k) => { if (filtersMapById[k]) filters.push(k); } );

        if (!filters.every(filterId => CONFIG_FILTERS.some(filter => filter.id === filterId))) {
            return input;
        }
        if (!(input instanceof Array)) {
            return input;
        }
        return input.filter((item)=> {
            return filters.some(filterId => CONFIG_FILTERS.find(filter => filter.id === filterId).filter(item, model));
        });
    }
}

export function specEditorTypeFilter() {
    return function (input, search) {
        if (search.length < 1) {
            return input;
        }
        return input.filter((item)=> {
            return item.miscData.get('typeName').indexOf(search) > -1 || item.type.indexOf(search) > -1;
        });
    }
}

function templateCache($templateCache) {
    $templateCache.put(TEMPLATE_URL, template);
}
