var ko      = require('knockout');

var dispose = require('./dispose');

/**
 * ObservableMap provides a structure to keep track of values that need to recalculate in
 * response to a key change or a mapping function change.
 *
 * @example
 * let factor = ko.observable(2);
 * let myFunc = ko.computed(() => {
 *   let f = factor();
 *   return (keyId) => key * f;
 * });
 *
 * let myMap = ObservableMap.create(myFunc);
 * let inObs1 = ko.observable(2);
 * let inObs2 = ko.observable(3);
 *
 * let outObs1 = myMap.add(inObs1);
 * let outObs2 = myMap.add(inObs2);
 * outObs1(); // 4
 * outObs2(); // 6
 *
 * inObs1(5);
 * outObs1(); // 10
 *
 * factor(3);
 * outObs1(); // 15
 * outObs2(); // 9
 *
 *
 * @param {Function} mapFunc - Computed that returns a mapping function that takes in a key and
 *  returns a value. Whenever `mapFunc` is updated, all the current values in the map will be
 *  recalculated using the new function.
 */
function ObservableMap(mapFunc) {
  this.store = new Map();
  this.mapFunc = mapFunc;

  // Recalculate all values on changes to mapFunc
  let mapFuncSub = mapFunc.subscribe(() => {
    this.updateAll();
  });

  // Disposes all stored observable and clears the map.
  this.autoDisposeCallback(() => {
    // Unsbuscribe from mapping function
    mapFuncSub.dispose();
    // Clear the store
    this.store.forEach((val, key) => val.forEach(obj => obj.dispose()));
    this.store.clear();
  });
}
dispose.makeDisposable(ObservableMap);

/**
 * Takes an observable for the key value and returns an observable for the output.
 * Subscribes to the given observable so that whenever it changes the output observable is
 * updated to the value returned by `mapFunc` when provided the new key as input.
 * If user disposes of the returned observable, it will be removed from the map.
 *
 * @param {ko.observable} obsKey
 * @return {ko.observble} Observable value equal to `mapFunc(obsKey())` that will be updated on
 *  updates to `obsKey` and `mapFunc`.
 */
ObservableMap.prototype.add = function (obsKey) {
  let currKey = obsKey();
  let ret = ko.observable(this.mapFunc()(currKey));

  // Add to map
  this._addKeyValue(currKey, ret);

  // Subscribe to changes to key
  let subs = obsKey.subscribe(newKey => {
    ret(this.mapFunc()(newKey));

    if (currKey !== newKey) {
      // If the key changed, add it to the new bucket and delete from the old one
      this._addKeyValue(newKey, ret);
      this._delete(currKey, ret);
      // And update the key
      currKey = newKey;
    }
  });
  ret.dispose = () => {
    // On dispose, delete from map unless the whole map is being disposed
    if (!this.isDisposed()) {
      this._delete(currKey, ret);
    }
    subs.dispose();
  };

  return ret;
};

/**
 * Returns the Set of observable values for the given key.
 */
ObservableMap.prototype.get = function (key) {
  return this.store.get(key);
};

ObservableMap.prototype._addKeyValue = function (key, value) {
  if (!this.store.has(key)) {
    this.store.set(key, new Set([value]));
  } else {
    this.store.get(key).add(value);
  }
};

/**
 * Triggers an update for all keys.
 */
ObservableMap.prototype.updateAll = function () {
  this.store.forEach((val, key) => this.updateKey(key));
};

/**
 * Triggers an update for all observables for given keys in the map.
 * @param {Array} keys
 */
ObservableMap.prototype.updateKeys = function (keys) {
  keys.forEach(key => this.updateKey(key));
};

/**
 * Triggers an update for all observables for the given key in the map.
 * @param {Any} key
 */
ObservableMap.prototype.updateKey = function (key) {
  if (this.store.has(key) && this.store.get(key).size > 0) {
    this.store.get(key).forEach(obj => {
      obj(this.mapFunc()(key));
    });
  }
};

/**
 * Given a key and an observable, deletes the observable from that key's bucket.
 *
 * @param {Any} key - Current value of the key.
 * @param {Any} obsValue - An observable previously returned by `add`.
 */
ObservableMap.prototype._delete = function (key, obsValue) {
  if (this.store.has(key) && this.store.get(key).size > 0) {
    this.store.get(key).delete(obsValue);
    // Clean up empty buckets
    if (this.store.get(key).size === 0) {
      this.store.delete(key);
    }
  }
};

module.exports = ObservableMap;