'use strict';
const assert = require('assert');
const _ = require('lodash');
const sortKeys = require('sort-keys');

/**
 * Proxy handler for Storage
 */
const proxyHandler = {
  get(storage, property) {
    return storage.get(property);
  },
  set(storage, property, value) {
    storage.set(property, value);
    return true;
  },
  ownKeys(storage) {
    return Reflect.ownKeys(storage._store);
  },
  has(target, prop) {
    return target.get(prop) !== undefined;
  },
  getOwnPropertyDescriptor(target, key) {
    return {
      get: () => this.get(target, key),
      enumerable: true,
      configurable: true
    };
  }
};

/**
 * Storage instances handle a json file where Generator authors can store data.
 *
 * The `Generator` class instantiate the storage named `config` by default.
 *
 * @constructor
 * @param {String} [name]     The name of the new storage (this is a namespace)
 * @param {mem-fs-editor} fs  A mem-fs editor instance
 * @param {String} configPath The filepath used as a storage.
 * @param {Object} [options] Storage options.
 * @param {Boolean} [options.lodashPath=false] Set true to treat name as a lodash path.
 * @param {Boolean} [options.disableCache=false] Set true to disable json object cache.
 * @param {Boolean} [options.disableCacheByFile=false] Set true to cleanup cache for every fs change.
 * @param {Boolean} [options.sorted=false] Set true to write sorted json.
 *
 * @example
 * class extend Generator {
 *   writing: function() {
 *     this.config.set('coffeescript', false);
 *   }
 * }
 */
class Storage {
  constructor(name, fs, configPath, options = {}) {
    if (name !== undefined && typeof name !== 'string') {
      configPath = fs;
      fs = name;
      name = undefined;
    }

    if (typeof options === 'boolean') {
      options = {lodashPath: options};
    }

    _.defaults(options, {
      lodashPath: false,
      disableCache: false,
      disableCacheByFile: false,
      sorted: false
    });

    assert(configPath, 'A config filepath is required to create a storage');

    this.path = configPath;
    this.name = name;
    this.fs = fs;
    this.indent = 2;
    this.lodashPath = options.lodashPath;
    this.disableCache = options.disableCache;
    this.disableCacheByFile = options.disableCacheByFile;
    this.sorted = options.sorted;

    this.existed = Object.keys(this._store).length > 0;

    this.fs.store.on('change', (filename) => {
      // At mem-fs 1.1.3 filename is not passed to the event.
      if (this.disableCacheByFile || (filename && filename !== this.path)) {
        return;
      }

      delete this._cachedStore;
    });
  }

  /**
   * Return the current store as JSON object
   * @return {Object} the store content
   * @private
   */
  get _store() {
    const store = this._cachedStore || this.fs.readJSON(this.path, {});
    if (!this.disableCache) {
      this._cachedStore = store;
    }

    if (!this.name) {
      return store || {};
    }

    return (this.lodashPath ? _.get(store, this.name) : store[this.name]) || {};
  }

  /**
   * Persist a configuration to disk
   * @param {Object} val - current configuration values
   * @private
   */
  _persist(value) {
    if (this.sorted) {
      value = sortKeys(value, {deep: true});
    }

    let fullStore;
    if (this.name) {
      fullStore = this.fs.readJSON(this.path, {});
      if (this.lodashPath) {
        _.set(fullStore, this.name, value);
      } else {
        fullStore[this.name] = value;
      }
    } else {
      fullStore = value;
    }

    this.fs.writeJSON(this.path, fullStore, null, this.indent);
  }

  /**
   * Save a new object of values
   */
  save() {
    this._persist(this._store);
  }

  /**
   * Get a stored value
   * @param  {String} key  The key under which the value is stored.
   * @return {*}           The stored value. Any JSON valid type could be returned
   */
  get(key) {
    return this._store[key];
  }

  /**
   * Get a stored value from a lodash path
   * @param  {String} path  The path under which the value is stored.
   * @return {*}           The stored value. Any JSON valid type could be returned
   */
  getPath(path) {
    return _.get(this._store, path);
  }

  /**
   * Get all the stored values
   * @return {Object}  key-value object
   */
  getAll() {
    return _.cloneDeep(this._store);
  }

  /**
   * Assign a key to a value and schedule a save.
   * @param {String} key  The key under which the value is stored
   * @param {*} val  Any valid JSON type value (String, Number, Array, Object).
   * @return {*} val  Whatever was passed in as val.
   */
  set(key, value) {
    assert(!_.isFunction(value), "Storage value can't be a function");

    const store = this._store;

    if (_.isObject(key)) {
      value = _.assignIn(store, key);
    } else {
      store[key] = value;
    }

    this._persist(store);
    return value;
  }

  /**
   * Assign a lodash path to a value and schedule a save.
   * @param {String} path  The key under which the value is stored
   * @param {*} val  Any valid JSON type value (String, Number, Array, Object).
   * @return {*} val  Whatever was passed in as val.
   */
  setPath(path, value) {
    assert(!_.isFunction(value), "Storage value can't be a function");

    const store = this._store;
    _.set(store, path, value);
    this._persist(store);
    return value;
  }

  /**
   * Delete a key from the store and schedule a save.
   * @param  {String} key  The key under which the value is stored.
   */
  delete(key) {
    const store = this._store;
    delete store[key];
    this._persist(store);
  }

  /**
   * Setup the store with defaults value and schedule a save.
   * If keys already exist, the initial value is kept.
   * @param  {Object} defaults  Key-value object to store.
   * @return {*} val  Returns the merged options.
   */
  defaults(defaults) {
    assert(
      _.isObject(defaults),
      'Storage `defaults` method only accept objects'
    );
    const store = _.defaults({}, this._store, defaults);
    this._persist(store);
    return this.getAll();
  }

  /**
   * @param  {Object} defaults  Key-value object to store.
   * @return {*} val  Returns the merged object.
   */
  merge(source) {
    assert(_.isObject(source), 'Storage `merge` method only accept objects');
    const value = _.merge({}, this._store, source);
    this._persist(value);
    return this.getAll();
  }

  /**
   * Create a child storage.
   * @param  {String} path - relative path of the key to create a new storage.
   *                         Some paths need to be escaped. Eg: ["dotted.path"]
   * @return {Storage} Returns a new Storage.
   */
  createStorage(path) {
    const childName = this.name ? `${this.name}.${path}` : path;
    return new Storage(childName, this.fs, this.path, true);
  }

  /**
   * Creates a proxy object.
   * @return {Object} proxy.
   */
  createProxy() {
    return new Proxy(this, proxyHandler);
  }
}

module.exports = Storage;