/*
Observable Store based in 2016 April by Ivan Kubota
License: MPL 2.0
Contact: zibx@quokka.pub
 */

/*
Observable Store based in 2016 April by Ivan Kubota
License: MPL 2.0
Contact: zibx@quokka.pub
 */
const isEqual = function(original, fn) {
  return function(val) {
    fn(val===original);
  }
};
const isContain = function(original, fn) {
  return function(val) {
    fn((val||[]).indexOf(original)>-1);
  }
};

/*
-- DESIGN --
Store MUST notify all subscribers on each change
Store MUST NOT notify subscribers if it does not change

 */
const Store = function(cfg) {
    Observable.call(this);
    this.events = new Observable();
    this._props = cfg || {};
  },
  isObject = function(obj) {
    // null is not an object. treat Date as primitive
    return typeof obj === 'object' && obj !== null && !(obj instanceof Date);
  };
const recursiveWalk = function(path, pointer, key, list) {
    if(typeof pointer[key] === 'object'){
      for(let subKey in pointer[key]){
        if(typeof pointer[key][subKey] === 'object'){
          list.push( [ path.concat( key, subKey ).join('.'), void 0 ] );
        }
        recursiveWalk(path.concat(key), pointer[key], subKey, list);
      }
    }else{
      list.push( [ path.concat( key ).join('.'), void 0 ] );
    }
  },
  recursiveSet = function(path, pointer, key, val, list){
    let subKey,i, originalLength;
    if( isObject(val) ){
      if(Array.isArray(val)){

        if(!Array.isArray(pointer[key])){
          pointer[ key ] = [];
          list.push([path.concat(key).join( '.' ), pointer[ key ]])
        }
        originalLength = pointer[ key ].length;
      }else{
        if(!isObject(pointer[ key ])){
          pointer[ key ] = {};
          list.push([path.concat(key).join( '.' ), pointer[ key ]])
        }
      }

      for(subKey in val){
        recursiveSet( path.concat( key ), pointer[ key ], subKey, val[subKey], list );
      }

      for( subKey in pointer[ key ] ){
        if( !val.hasOwnProperty( subKey ) ){
          // remove
          recursiveWalk( path.concat( key ), pointer[key], subKey, list );
          delete pointer[ key ][ subKey ];
        }
      }
      if(Array.isArray(val)){
        if(originalLength !== val.length){
          recursiveSet( path.concat( key ), pointer[ key ], 'length', val.length, list );
          list.push([path.concat(key).join( '.' ), pointer[ key ]]);
        }
      }
    }else{
      if( val !== pointer[ key ] ){
        pointer[ key ] = val;
        list.push( [ path.concat(key).join( '.' ), pointer[ key ] ] );
      }

    }
  };
var StoreParent = function(parent, key, item) {
  this.parent = parent;
  this.key = key;
  this.item = item;
};
StoreParent.prototype = {
  getKey: function() {
    if(this.item){
      var idx = this.parent._props.indexOf(this.item);
      if(idx > -1)
        return idx;
      console.error('try to write to not existed item');
    }else{
      return this.key;
    }
  }
};
Store.prototype = {
  parent: null,
  setParent: function(parent, key, item){
    this.parent = new StoreParent(parent, key, item);
  },
  bindings: function() {
    var out = {};
    for(var k in this._props){
      out[k] = this.bind(k);
    }
    return out;
  },
  item: function(key, item){
    let subStore = new Store(item?item:key);
    subStore.setParent(this, key, item);
    return subStore;
  },
  array: function(key){
    let arrayStore = new ArrayStore(this.get(key));
    arrayStore.setParent(this, key);
    return arrayStore;
  },
  experimental: false,
  _set: function(keys, val, pointer, changeList) {
    changeList = changeList || [];
    let parent, i, _i;
    for(i=0, _i = keys.length - 1; i < _i; i++){
      let key = keys[i];
      parent = pointer;

      if(key in pointer){
        pointer = parent[key];
        if(!isObject(pointer)){
          pointer = parent[key] = {};
          changeList.push([keys.slice(0, i).join( '.' ), parent[key]]);
        }
      }else{
        pointer = parent[key] = {};
        changeList.push([keys.slice(0, i+1).join( '.' ), parent[key]]);
      }

    }

    let key = keys[i];
    recursiveSet(keys.slice(0, keys.length - 1), pointer, key, val, changeList);
  },
  clear: function() {
    this._props = {};
    this.events.fire('change', null, null);
  },
  /*
  key: String, val: any
  key: {k: v, ...}
   */
  set: function(key, val, changeList) {
    var isChangeList = changeList !== void 0;
    changeList = changeList || [];

    var type = typeof key;

    if(type === 'object'){
      for(let k in key){
        this.set(k, key[k], changeList);
      }
      this._notify(changeList);
      return this;
    }

    var _key;
    if(type === 'string') {
      _key = key.split('.');
    }else if(type === 'number'){
      _key = [key];

    }

    this._set(
      _key,
      val,
      this._props,
      changeList
    );
    if(!isChangeList)
      this._notify(changeList);

    return this;
  },
  reSet: function(key, val, changeList) {
    this._props = {};
    if(key===void 0)
      return this;
    return this.set(key, val, changeList);
  },
  _notify: function(changeList, prefix) {
    prefix = prefix || '';
    for( let i = changeList.length; i; ){
      const changeListElement = changeList[ --i ];
      var key = changeListElement[0], val = changeListElement[1],
        fullKey = prefix+(key===''?'':(prefix===''?'':'.')+key);
      this.events.fire('change', fullKey, changeListElement[1]);
      this.fire(fullKey, changeListElement[1]);

    }
    if(this.parent){
      if(prefix) {
        this.parent.parent._notify(changeList, this.parent.getKey() + '.' + prefix);
      }else{
        this.parent.parent._notify(changeList, this.parent.getKey());

      }
    }
  },
  get: function(key, returnLastStore) {
    if(key === void 0)
      return this._props;

    let type = typeof key;
    if(type === 'string') {
      key = key.split('.');
    }
    if(type === 'number') {
      key = [key];
    }

    let ref = this,
      lastStore = ref, i, _i;

    for( i = 0, _i = key.length; i < _i; i++ ){
      if( ref instanceof Store ){
        ref = ref._props[key[i]];
        lastStore = ref;
      }else{
        ref = ref[key[i]];
      }

      if( !ref && i < key.length - 1 )
        return void 0;
    }

    if( returnLastStore )
      return { lastQObject: lastStore, result: ref };

    return ref;
  },

  sub: function(key, fn, suppresFirstCall) {
    var un;
    if(Array.isArray(key)){
      var _self = this;

      var args = new Array(key.length);

      var caller = function() {
        return fn.apply(_self, args);
      };
      var wrap = function(i){
        return function(val) {
          args[i] = val;
          return caller();
        };
      };

      var uns = [];
      for( var i = 0, _i = key.length; i < _i; i++ ){
        if(key[i] instanceof StoreBinding){
          uns.push(key[i].sub(wrap(i)));
          args[i] = key[i].get();
        }else if(key[i] instanceof HookPrototype){
          uns.push(key[i].hook(wrap(i)));
          args[i] = key[i].get();
        }else{
          uns.push(this.on( key[ i ], wrap(i) ));
          args[i] = this.get(key[ i ]);
        }


      }
      !suppresFirstCall && wrap();
      return function() {
        for( var i = 0, _i = uns.length; i < _i; i++ ){
          uns[ i ]();
        }
      };
    }else{
      un = this.on( key, fn );
      !suppresFirstCall && fn( this.get( key ) );
      return un;
    }

  },
  equal: function(key, val, fn) {
    const wrap = isEqual(val, fn);
    this.on(key, wrap);
    wrap(this.get(key));
    return this;
  },
  contain: function(key, val, fn) {
    const wrap = isContain(val, fn);
    this.on(key, wrap);
    wrap(this.get(key));
    return this;
  },
  bind: function (key) {
    return new StoreBinding(this, key);
  },
  val: function(key) {
    const me = this;
    return function backwardCallback(update) {
      me.sub(key, val => update(val));
    }
  },
  valEqual: function(key, val) {
    const me = this;
    return function backwardCallback(update) {
      me.equal(key, val, compareResult => {update(compareResult)});
    }
  },
  valEqualOnly: function(key, val) {
    const me = this;
    return function backwardCallback(update) {
      me.equal(key, val, compareResult => {compareResult && update(compareResult)});
    }
  },
  valContains: function(key, val) {
    const me = this;
    return function backwardCallback(update) {
      me.contain(key, val, compareResult => {compareResult && update(compareResult)});
    }
  },
  valTrue: function(key) {
    return this.valEqual(key, true);
  },
  valFalse: function(key) {
    return this.valEqual(key, false);
  }
};
const StoreBinding = function(store, key){
  this.store = store;
  this.key = key;
};
StoreBinding.prototype = {
  sub: function (k, fn) {
    if(typeof k === 'function'){
      fn = k;
      return this.un = this.store.sub(this.key, fn);
    }else{
      return this.un = this.store.sub(
        (Array.isArray(k)?k:[k]).map(k=>this.key+'.'+k), fn);
    }

  },
  set: function (val) {
    this.store.set(this.key, val);
  },
  get: function() {
    return this.store.get(this.key);
  },
  bind: function(key) {
    return new StoreBinding(this.store, this.key+'.'+key);
  },
  hook: function(draw) {
    return this.sub(function(val){
      draw(val);
    });
  },
  array: function() {
    return this.store.array(this.key);
  }
};
Store.prototype = Object.assign(new Observable, Store.prototype);

Store.StoreBinding = StoreBinding;

Store.AGGREGATE = function(fn) {
  return function(...args) {
    let composite, _i = args.length,
      vals = new Array(_i);
    return function(update) {
      let check = ()=>{
        let result = fn(vals, _i);

        if(composite !== result)
          update(composite = result);
      };
      for(let i = 0; i < _i; i++){
        // set backward callback
        let hook = val => {
          // update item corresponding value and check condition
          vals[ i ] = val;
          check();
        };
        if(args[i] instanceof HookPrototype){
          args[i].hook(hook)
        }else{
          args[ i ]( hook )
        }
      }

    };

  };
};

Store.AND = Store.AGGREGATE(function(values, length) {
  let result = true;
  for(let i = 0; i < length; i++){
    result = result && values[i];
  }
  return result;
});

Store.OR = Store.AGGREGATE(function(values, length) {
  let result = false;
  for(let i = 0; i < length; i++){
    result = result || values[i];
  }
  return result;
});
Store.ELSE = function(){if(!( this instanceof Store.ELSE))return new Store.ELSE()};
Store.IF = function(cfg, children){
  var holders = {true: [], false: []},
    holder = holders.true;
  for( var i = 0, _i = children.length; i < _i; i++ ){
    var child = children[ i ];
    if(child instanceof Store.ELSE){
      holder = holders.false;
    }else{
      holder.push(child);
    }
  }
  holders.true.length === 0 && (holders.true = null);
  holders.false.length === 0 && (holders.false = null);

  return function( update ){
    if( 'condition' in cfg )
      var hook = function( cond ){
        update( cond ? holders.true : holders.false );
      };
    if(cfg.condition instanceof HookPrototype){
      cfg.condition.hook( hook );
    }else if(typeof cfg.condition === 'function'){
      cfg.condition( hook );
    }else{
      // TODO other hooklikes
      hook(cfg.condition)
    }
  }
};
Store.NOT = Store.AGGREGATE(function(values, length) {
  return !values[0];
});
Store.debounce = function(fn, dt) {
  var timeout = false, args, scope,
    realCall = function() {
      timeout = false;
      fn.apply(scope, args)
    };
  return function(){
    args = [].slice.call(arguments);
    scope = this;
    if(!timeout){
      timeout = setTimeout(realCall, dt);
    }
  }
};
var HookPrototype = function() {};
HookPrototype.prototype = {
  setter: function(val) { return val; },
  //getter: function(val) { return val; },
  set: function(val) {
    val = this.setter(val);
    var oldVal = this.get();
    if(!this.equal(oldVal, val)){
      this.data = val;
      this._emit(oldVal, val);
    }
  },
  equal: function(oldVal, newVal){
    return newVal === oldVal;
  },
  get: function() {
    return this.data;
  },
  binding: function() {
    var x = new StoreBinding();
    x.get = ()=> this.get();
    x.set = (a)=> this.set(a);
    x.sub = (fn)=> this.hook(fn);
    return x;
  },
  hook: function(fn) {
    this.subscribers.push(fn);
    fn(this.get());
    var _self = this;
    return function() {
      var index = _self.subscribers.indexOf(fn);
      if(index>-1){
        _self.subscribers.splice( index, 1 );
      }
    }
  },
  _emit: function(oldVal, val) {
    var subscribers = this.subscribers;
    for( var i = 0, _i = subscribers.length; i < _i; i++ ){
      subscribers[ i ](val);
    }
  }
};
var HookFactory = function(accessor) {
  var Hook = function(cfg) {
    if(!(this instanceof Hook))
      return new Hook(cfg);
    this.data = {};
    this.subscribers = [];
    this.set(cfg);
  };
  Hook.prototype = Object.assign( new HookPrototype(), accessor );

  return Hook;
};

Store.getValue = function(val) {
  if(val instanceof StoreBinding){
    return val.hook();
  }else{
    return val;
  }
};

Store.Value = {
  Boolean: new HookFactory({
    setter: function(val) { return !!val; },
    toggle: function() { this.set(!this.get()); }
  }),
  Number: new HookFactory({
    setter: function(val) { return val-0; }
  }),
  String: new HookFactory({
    setter: function(val) { return val+''; }
  }),
  Integer: new HookFactory({
    setter: function(val) { return val|0; }
  }),
  Any: new HookFactory(),
  Function: new HookFactory()
};
Store.HookPrototype = HookPrototype;

/*
-- DESIGN --
Array DOES NOT notify the store when it's item changes
Array MUST notify itself when item is added or removed
Array MUST notify when it is reordered
Array Item MUST be observed itself
 */

var getter = function( i ){ return this[i]; };
var fns = Array.prototype;

const ArrayStore = function(cfg) {
  Store.call(this, cfg);
  if( !('get' in this._props) ){
    Object.defineProperties( this._props, {
      get: { value: getter, enumerable: false }
    } );
  }
  this.length = this._props.length;
};

ArrayStore.prototype = {
  length: 0,
  indexOf: function (a) {
    return this._props.indexOf(a);
  },
  toArray: function () {
    return this._props;
  },
  _fireAdd: function (item, pos) {
    var arr = this._props;
    this.fire('add', item, pos > 0 ? arr.get(pos-1) : null, pos < this.length - 1 ? arr.get(pos+1) : null, pos)
  },
  _fireRemove: function (item, pos) {
    var arr = this._props;
    this.fire('remove', item, pos > 0 ? arr.get(pos-1) : null, pos < this.length ? arr.get(pos) : null, pos)
  },
  push: function (item) {
    // single item push only
    var out = this._props.push(item);
    this._fireAdd(item, this.length++);
    return out;
  },
  unshift: function (item) {
    // single item unshift only
    var out = this._props.unshift(item);
    this.length++;
    this._fireAdd(item, 0);
    return out;
  },
  shift: function(){
    var pos = --this.length,
      arr = this._props,
      item = arr.shift();

    this._fireRemove(item, 0);
    return item;
  },
  pop: function () {
    var pos = --this.length,
      arr = this._props,
      item = arr.pop();

    this._fireRemove(item, pos);
    return item;
  },
  splice: function(start, count){
    var i, _i, newItems = fns.slice.call(arguments,2 ), out = [];
    for(i = 0;i<count; i++)
      out.push(this.remove(start));

    for(i = 0, _i = newItems.length; i < _i; i++)
      this.insert(newItems[i], i + start);

    return out;
  },
  /*
  set - updates element
  @arg pos: position of element. defined on [0..length]
  @arg item: element to set

  @return element that was in that position before
   */
  set: function(pos, item){
    if(pos === this.length){
      this.push( item );
      return void 0; // for same behavior we return empty array
    }
    return this.splice(pos, 1, item)[0];
  },
  iterator: function(start){
    return new Iterator(this, start);
  },
  remove: function(pos){
    var item = this._props.splice(pos,1)[0];
    this.length--;
    this._fireRemove(item, pos);
    return item;
  },
  insert: function(item, pos){
    this._props.splice(pos, 0, item);
    this.length++;
    this._fireAdd(item, pos)
  },
  forEach: function (fn) {
    return this._props.forEach(fn);
  }
};
ArrayStore.prototype = Object.assign(new Store(), ArrayStore.prototype);

typeof module === 'object' && (module.exports = Store);
(typeof window === 'object') && (window.Store = Store);