/*
 * Copyright 2015, Intel Corp.
 *
 * Licensed 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.
 */
(function () {
  'use strict';

  angular
    .module('horizon.framework.widgets.metadata.tree')
    .factory('horizon.framework.widgets.metadata.tree.service', metadataTreeService);

  /**
   * @ngdoc service
   * @name metadataTreeService
   */
  function metadataTreeService() {
    var service = {
      Item: Item,
      Property: Property,
      Tree: Tree
    };

    /**
     * Construct a new property
     *
     * @class Property
     * @param {string} name
     * @param {Object} [json]
     *
     * @property {string} name Property key name
     * @property {string} title Property display name
     * @property {string} description Property description
     * @property {*} value Property value
     * @property {string} default Property default value
     * @property {string} type Property type
     * @property {boolean} readonly Property readonly state
     * @property {string[]} operators Property available operators when type='array'
     * @property {string} operator Property operator when type='array'
     */
    function Property(name, json) {
      this.name = name;
      this.title = name;
      this.description = '';
      this.value = null;
      this.default = null;
      this.type = 'string';
      this.readonly = false;
      this.required = true;
      this.operators = ['<in>'];
      angular.extend(this, json);
      this.operator = this.operators[0];
      this.setValue(this.default);
    }

    /**
     * Deserialize value and assign it to {@link Property#value}
     *
     * @param  {string} value
     */
    Property.prototype.setValue = function (value) {
      if (value === null) {
        this.value = this.type !== 'array' ? null : [];
        // if the existing property is null, make the field not required
        this.required = false;
        return;
      }

      // if the existing property has empty string value, make the field not required
      if (value === '') {
        this.required = false;
      }

      switch (this.type) {
        case 'integer':
          this.value = parseInt(value, 10);
          break;
        case 'number':
          this.value = parseFloat(value);
          break;
        case 'array':
          var data = /^(<.*?>) (.*)$/.exec(value);
          if (data && data.length > 2) {
            this.operator = data[1];
            this.value = data[2].split(',');
          }
          break;
        case 'boolean':
          this.value = parseBool(value);
          break;
        default:
          this.value = value;
      }
    };

    /**
     * Serialize {@link Property#value} and returns it
     *
     * @returns {string}
     */
    Property.prototype.getValue = function () {
      switch (this.type) {
        case 'array':
          return this.operator + ' ' + this.value.join(',');
        default:
          return '' + this.value;
      }
    };

    /**
     * Construct a new tree node
     *
     * @class Item
     * @param {Item=} parent
     *
     * @property {Item} parent Item parent
     * @property {Item[]} children Item children
     * @property {boolean} visible Item visibility
     */
    function Item(parent) {
      // parent as property to prevent infinite recursion in angular filter
      Object.defineProperty(this, 'parent', {
        value: parent || null
      });
      this.children = [];
      // Node properties
      this.visible = false;
      this.expanded = false;
      this.label = '';
      this.description = '';
      this.level = parent ? parent.level + 1 : 0;
      this.addedCount = 0;
      this.custom = false;
      // Leaf properties
      this.leaf = false;
      this.added = false;
    }

    /**
     * Load Item values and child Items from namespace definition
     *
     * @param {object} namespace Metadata namespace definition
     * @returns {Item}
     */
    Item.prototype.fromNamespace = function (namespace) {
      this.label = namespace.display_name;
      this.description = namespace.description;

      if (namespace.objects) {
        angular.forEach(namespace.objects, function (object) {
          this.children.push(new Item(this).fromObject(object));
        }, this);
      }

      if (namespace.properties) {
        angular.forEach(namespace.properties, function (property, key) {
          this.children.push(new Item(this).fromProperty(key, property));
        }, this);
      }

      this.sortChildren();

      return this;
    };

    /**
     * Load Item values and child Items from object definition
     *
     * @param {object} object Metadata object definition
     * @returns {Item}
     */
    Item.prototype.fromObject = function (object) {
      this.label = object.name;
      this.description = object.description;

      if (object.properties) {
        angular.forEach(object.properties, function (property, key) {
          this.children.push(new Item(this).fromProperty(key, property));
        }, this);
      }

      this.sortChildren();

      return this;
    };

    /**
     * Load Item values from property definition
     *
     * @param {string} name Property name
     * @param {object} property Metadata property definition
     * @returns {Item}
     */
    Item.prototype.fromProperty = function (name, property) {
      this.leaf = new Property(name, property);
      this.label = this.leaf.title;
      this.description = this.leaf.description;

      return this;
    };

    /**
     * Load Item values from property definition and mark as custom
     *
     * @param {string} name Property name
     * @param {object=} property Metadata property definition
     * @returns {Item}
     */
    Item.prototype.customProperty = function (name, property) {
      this.fromProperty(name, property);
      this.custom = true;

      return this;
    };

    /**
     * Expand Item by marking all children as visible
     *
     * @param {boolean=} deep Whether to recursively expand all child Items
     */
    Item.prototype.expand = function (deep) {
      this.expanded = true;
      angular.forEach(this.children, function (child) {
        if (deep) {
          child.expand(deep);
        }
        child.visible = true;
      }, this);
    };

    /**
     * Collapse Item by recursively unmarking all children as visible
     */
    Item.prototype.collapse = function () {
      this.expanded = false;
      angular.forEach(this.children, function (child) {
        child.collapse();
        child.visible = false;
      }, this);
    };

    /**
     * Sort children Items by label
     */
    Item.prototype.sortChildren = function () {
      this.children.sort(function (a, b) {
        return a.label.localeCompare(b.label);
      });
    };

    /**
     * Recursively mark Item and all children as added
     *
     * @param {=} caller Used internally to prevent infinite recursion
     */
    Item.prototype.markAsAdded = function (caller) {
      if (this.parent && !this.added) {
        this.parent.addedCount += 1;
        if (this.parent.addedCount === this.parent.children.length) {
          this.parent.markAsAdded(this);
        }
      }
      this.added = true;
      if (!caller) { // prevent infinite recursion
        angular.forEach(this.children, function (item) {
          item.markAsAdded();
        }, this);
      }
    };

    /**
     * Recursively unmark Item and all children as added
     *
     * @param {boolean=} expand Whether to expand parent of unmarked Item
     * @param {=} caller Used internally to prevent infinite recursion
     */
    Item.prototype.unmarkAsAdded = function (expand, caller) {
      if (this.parent) {
        if (expand) {
          this.parent.expand();
        }
        if (this.added) {
          this.parent.addedCount -= 1;
          this.parent.unmarkAsAdded(expand, this);
        }
      }
      this.added = false;
      if (!caller) { // prevent infinite recursion
        angular.forEach(this.children, function (item) {
          item.unmarkAsAdded(expand);
        }, this);
      }
    };

    /**
     * Returns list of Items from top-most parent to this Item
     *
     * @param {[]=} path Used internally
     * @returns {Item[]}
     */
    Item.prototype.path = function (path) {
      path = angular.isDefined(path) ? path : [];
      if (this.parent) {
        this.parent.path(path);
      }
      path.push(this);
      return path;
    };

    /**
     * Returns breadcrumb string for this Item
     *
     * @returns {string}
     */
    Item.prototype.breadcrumb = function () {
      return this.path().map(function (item) {
        return item.label;
      }).join(' › ');
    };

    /**
     * Parse string parameter into leaf value
     *
     * @param {string} value
     */
    Item.prototype.setLeafValue = function (value) {
      if (this.leaf) {
        this.leaf.setValue(value);
      }
    };

    /**
     * Serialize leaf value into string
     *
     * @returns {string}
     */
    Item.prototype.getLeafValue = function () {
      if (this.leaf) {
        return this.leaf.getValue();
      }
    };

    /**
     * Construct a new tree
     *
     * @class Tree
     * @param {object[]} available List of available namespaces
     * @param {object} existing Key-value pairs for existing metadata
     *
     * @property {Item[]} tree List available namespaces parsed into Item-s
     * @property {Item[]} flatTree List of Item-s flattened from tree structure
     * @property {Item} selected Selected Item
     */
    function Tree(available, existing) {
      this.tree = [];
      this.loadNamespaces(available);
      this.flatTree = this.flatten(this.tree);
      this.selected = null;
      this.loadExisting(existing);
    }

    /**
     * Load Item values and child Items from namespace definition
     *
     * @param {object[]} namespaces list of Metadata namespace definitions
     * @returns {Tree}
     */
    Tree.prototype.loadNamespaces = function (namespaces) {
      angular.forEach(namespaces, function (namespace) {
        var item = new Item().fromNamespace(namespace);
        item.visible = true;
        this.tree.push(item);
      }, this);

      this.tree.sort(function (a, b) {
        return a.label.localeCompare(b.label);
      });

      return this;
    };

    /**
     * Crete flat representation of branch
     *
     * @param {Item[]} branch List of Items to flatten
     * @param {[]=} items Used internally
     * @returns {Item[]}
     */
    Tree.prototype.flatten = function (branch, items) {
      items = angular.isDefined(items) ? items : [];

      angular.forEach(branch, function (item) {
        items.push(item);
        this.flatten(item.children, items);
      }, this);

      return items;
    };

    /**
     * Load Property.value for each value from existing and mark corresponding
     * Items as added. If no corresponding Item is found new Item is added and
     * marked as custom.
     *
     * @param {object} existing
     */
    Tree.prototype.loadExisting = function (existing) {
      var itemsMapping = {};

      angular.forEach(this.flatTree, function (item) {
        if (item.leaf) {
          angular.forEach(existing, function caseInsensitiveCompare(value, key) {
            var caseInsensitiveLeafName = item.leaf.name.toLocaleLowerCase();
            if (caseInsensitiveLeafName === key.toLocaleLowerCase()) {
              itemsMapping[caseInsensitiveLeafName] = item;
            }
          });
        }
      });

      angular.forEach(existing, function (value, key) {
        var item = itemsMapping[key.toLocaleLowerCase()];
        if (angular.isUndefined(item)) {
          item = new Item().customProperty(key);
          this.flatTree.push(item);
        }
        item.setLeafValue(value);
        item.markAsAdded();
      }, this);
    };

    /**
     * Returns key-value mapping of leaf Items that was marked as added
     *
     * @returns {object}
     */
    Tree.prototype.getExisting = function () {
      var existing = {};
      angular.forEach(this.flatTree, function (item) {
        if (item.added && item.leaf) {
          if (item.leaf.type === 'array' && item.leaf.value.length === 0) {
            // Do nothing, the user has removed or not selected any array values
          } else {
            existing[item.leaf.name] = item.getLeafValue();
          }
        }
      });
      return existing;
    };

    /**
     * Selects item and expands / collapses it
     *
     * @param {Item} item
     */
    Tree.prototype.select = function (item) {
      this.selected = item;
      if (!item.expanded) {
        item.expand();
      } else {
        item.collapse();
      }
    };

    /**
     * Selects item and marks it as added
     *
     * @param {Item} item
     */
    Tree.prototype.markAsAdded = function (item) {
      this.selected = item;
      item.markAsAdded();
    };

    /**
     * Selects item, unmarks it as added and expands it's parent
     *
     * @param {Item} item
     */
    Tree.prototype.unmarkAsAdded = function (item) {
      if (!item.custom) {
        this.selected = item;
        item.unmarkAsAdded(true);
      } else {
        this.selected = null;
        var i = this.flatTree.indexOf(item);
        if (i > -1) {
          this.flatTree.splice(i, 1);
        }
      }
    };

    /**
     * Adds new Item, selects it and marks it as custom and added
     *
     * @param {string} name Name of leaf
     */
    Tree.prototype.addCustom = function (name) {
      var item = new Item().customProperty(name);
      item.markAsAdded();
      this.flatTree.push(item);
      this.selected = item;
    };

    /**
     * Parse value into boolean
     *
     * @param {(string|boolean)} value
     * @returns {boolean}
     */
    function parseBool(value) {
      if (value === true || value === false) {
        return value;
      } else if (angular.isString(value)) {
        value = value.toLowerCase();

        if (value === 'true') {
          return true;
        } else if (value === 'false') {
          return false;
        }
      }

      return null;
    }

    return service;
  }
})();
