Show:
'use strict';

const path = require('path');
const fs = require('fs');
const _ = require('lodash');
const Promise = require('bluebird');
const traverse = require('traverse');
const jsonSchema = require('jsonschema');
const uuid = require('uuid');
const AnystoreError = require('./anystore-error');
const logger = require('hw-logger');
const log = logger.log;

class Anystore {
  /**
   * Base class of any store.
   *
   * @class Anystore
   * @constructor
   * @param [opt] {Object} Anystore options
   */
  constructor(opt) {
    opt = opt || {};
    _.defaults(this, _.pick(opt, ['schemas']));
    this._computeSchema();
  }

  /**
   * Optionnal initialization.
   * Use it to pass optional options to the store implementation, like host, port, ... for example.
   *
   * @method init
   * @param [opt] {Object} Store implementation options
   * @return {Boolean} true on success
   */
  init(opt) {
    const result = typeof this._init === 'function'
      ? this._init(opt && _.clone(opt) || {})
      : true;
    this.initialized = true;
    return result;
  }

  /**
   * Start the store.
   *
   * @async
   * @method start
   * @param [opt] {Object} Store implementation options
   * @param [cb] {Function} Callback function
   * @return {Promise} true on success
   */
  start(opt, cb) {
    if (typeof cb === 'undefined' && typeof opt === 'function') {
      cb = opt;
      opt = null;
    }
    opt = opt && _.clone(opt) || {};
    return Promise.resolve()
      .then(() => {
        if (!this.initialized) {
          this.init(opt);
        }
        if (this.started) {
          return false;
        }
        return Promise.resolve()
          .then(() => {
            if (!this.initialized) {
              this.init(opt);
            }
            if (typeof this._start === 'function') {
              return this._start();
            }
          })
          .then(() => {
            this.started = true;
            logger.enabledLevels.info && log.info(`${this.name} sucessfully started`);
            return true;
          });
      })
      .asCallback(cb);
  }

  /**
   * Stop the store.
   *
   * @async
   * @method stop
   * @param [cb] {Function} Callback function
   * @return {Promise} true on success
   */
  stop(cb) {
    return Promise.resolve()
      .then(() => {
        if (!this.started) {
          return false;
        }
        return Promise.resolve()
          .then(() => {
            if (typeof this._stop === 'function') {
              return this._stop();
            }
          })
          .then(() => {
            logger.enabledLevels.info && log.info(`${this.name} closed`);
            return true;
          });
      })
      .asCallback(cb);
  }

  _computeSchema() {
    if (!this.schemas) {
      return;
    }
    const rels = {};
    this.validator = new jsonSchema.Validator();
    this.validator.addSchema(Anystore.idSchema, '/Id');
    for (const schema of this.schemas) {
      if (_.get(schema, 'properties.id')) {
        this.validator.addSchema(Anystore.idSchema, '/Id' + schema.id);
      }
      const jsonSchema = Object.assign({}, _.omit(schema, 'id'), {id: `/${schema.id}`});
      jsonSchema.properties = jsonSchema.properties || {};
      jsonSchema.$update = _.defaults(jsonSchema.$update, {
        required: ['id'],
        minProperties: 2
      });
      Object.assign(jsonSchema.properties, {id: {$ref: '/Id'}});
      this.validator.addSchema(jsonSchema, jsonSchema.id);
      traverse(schema).forEach(function(item) {
        const type = schema.id;
        rels[type] = rels[type] || {indexes: [], links: {}};
        if (this.key === '$target') {
          const key = this.path[1];
          const links = rels[type].links;
          links[key] = links[key] || {};
          const link = links[key];
          link[_.get(schema, this.path.slice(0, 2)).type === 'array' ? 'hasMany' : 'hasOne'] = true;
          link.target = item;
          const as = _.get(schema, this.parent.path).$as;
          if (as) {
            link.as = as;
          }
        } else if (this.key === '$unique') {
          const indexes = rels[type].indexes;
          const key = this.parent.key;
          if (indexes.indexOf(key) === -1) {
            indexes.push({name: key, unique: true});
          }
        } else if (this.key === '$uniqueWith') {
          const indexes = rels[type].indexes;
          const index = [this.parent.key, item];
          if (!indexes
              .reduce((notFound, existingIndex) => {
                if (!Array.isArray(existingIndex.name)) {
                  return false;
                }
                return (index.length === existingIndex.name.length) && index
                    .every((e, i) => existingIndex.name.indexOf(e) !== -1);
              }, false)) {
            indexes.push({name: [this.parent.key, item], unique: true});
          }
        } else if (this.key === '$index') {
          const indexes = rels[type].indexes;
          const key = this.parent.key;
          if (indexes.indexOf(key) === -1) {
            indexes.push({name: key});
          }
        }
      });
    }
    for (const type in rels) {
      const typeRel = rels[type];
      for (const key in typeRel) {
        const itemRel = typeRel[key];
        if (itemRel.target) {
          if (!rels[itemRel.target]) {
            throw new AnystoreError.SchemaValidation(
              `Error in "${type}" schema : target "${itemRel.target}" does not exist`);
          }
          if (itemRel.as) {
            if (!rels[itemRel.target][itemRel.as]) {
              throw new AnystoreError.SchemaValidation(
                `Error in "${type}" schema : target "${itemRel.target}.${itemRel.as}" does not exist`);
            }
          }
        } else {
          if (itemRel.as) {
            throw new AnystoreError.SchemaValidation(
              `Error in "${type}" schema : missing target with as attribute`);
          }
        }
      }
    }
    this.schemaRelations = rels;
  }

  _applyImpl(fnName, ...args) {
    const implFn = this.impl[`_${fnName}`];
    if (typeof implFn !== 'function') {
      throw new AnystoreError
        .NotImplemented(`method "${fnName}" not implemented in store implementation "${this.impl.name}"`);
    }
    return implFn(...args);
  }

  /**
   * Get schema relations.
   *
   * @method getSchemaRelations
   * @return {Object} Schema relation
   */
  getSchemaRelations() {
    return this.schemaRelations;
  }

  /**
   * Validate entity data.
   *
   * @async
   * @method validate
   * @param type {String} Entity type reference
   * @param data {Object} Store implementation options
   * @param [mode=create] {String} Specify operation to use in schema
   * @param [cb] {Function} Callback function
   * @return {Promise} fails on validation error
   */
  validate(type, data, mode = 'create', cb) {
    return Promise.resolve()
      .then(() => {
        if (typeof data !== 'object' || !data) {
          throw new AnystoreError.MissingArgument('missing data');
        }
        if (!this.validator) {
          return;
        }
        const schema = this.validator.getSchema(`/${type}`);
        if (!schema) {
          throw new AnystoreError.SchemaValidation(`schema not found for "${type}"`);
        }
        return Promise.resolve()
          .then(() => {
            const modeSchema = _.chain(schema).omit(['$create', '$update']).assign(schema[`$${mode}`]).value();
            const defaults = Object.keys(modeSchema.properties)
              .filter(name => typeof modeSchema.properties[name].default !== 'undefined')
              .reduce((o, name) => Object.assign(o, {[name]: modeSchema.properties[name].default}), {});
            _.defaults(data, defaults);
            this.validator.schemas[schema.id] = modeSchema;
            const validation = this.validator.validate(data, type);
            if (!validation.valid) {
              logger.enabledLevels.debug && log.debug('validation :', JSON.stringify(validation, null, 2));
              throw new AnystoreError.SchemaValidation('bad data format', validation.errors);
            }
            return Promise.resolve()
              .then(() => {
                const indexes = this.getIndexes(type, data);
                return Promise.each(indexes, index => {
                  if (!index.unique) {
                    return;
                  }
                  const indexValue = Array.isArray(index.name)
                    ? index.name.map(key => data[key])
                    : data[index.name];
                  return this.findByIndex(type, index.name, indexValue)
                    .then(list => {
                      if ((data.id ? list.filter(item => item.id !== data.id) : list).length) {
                        throw new AnystoreError
                          .Conflict(`${type}.${index.name} already exists with value "${indexValue}"`);
                      }
                    });
                });
              });
          })
          .catch(jsonSchema.SchemaError, err => {
            throw new AnystoreError.SchemaValidation(err);
          })
          .finally(() => {
            this.validator.schemas[schema.id] = schema;
          });
      })
      .asCallback(cb);
  }

  getIndexes(type, data) {
    return (this.schemaRelations[type].indexes || [])
      .filter(index => !data || (Array.isArray(index.name)
        ? index.name.reduce((ok, indexName) => ok && typeof data[indexName] !== 'undefined', true)
        : typeof data[index.name] !== 'undefined')
      );
  }

  getLinks(type, data) {
    const links = this.schemaRelations[type] && this.schemaRelations[type].links || {};
    return Object.keys(links).reduce((list, name) => {
      const value = links[name];
      if (value && (!data || typeof data[name] !== 'undefined')) {
        list.push({name, value});
      }
      return list;
    }, []);
  }

  unsetRelations(type, data, cb) {
    return Promise.resolve()
      .then(() => {
        if (!this.validator) {
          return;
        }
        logger.enabledLevels.debug && log.debug(`removing relations for ${type}#${data.id}`);
        if (typeof this._unsetRelations === 'function') {
          return this._unsetRelations(type, data);
        }
        logger.enabledLevels.debug && log.debug(`removing links for ${type}#${data.id}`);
        return Promise.each(this.getLinks(type, data), link => Promise.resolve()
          .then(() => this._load(link.value.target, data[link.name]))
          .then(rData => {
            if (link.value.hasMany) {
              const index = rData[link.value.as].indexOf(data.id);
              if (index !== -1) {
                rData[link.value.as].splice(index, 1);
              }
            } else if (link.value.hasOne) {
              delete rData[link.value.as];
            }
            return this.update(type, rData, {cascade: false});
          })
        );
      })
      .return(data)
      .asCallback(cb);
  }

  setRelations(type, data, cb) {
    return Promise.resolve()
      .then(() => {
        if (!this.validator) {
          return;
        }
        logger.enabledLevels.debug && log.debug(`setting relations for ${type}#${data.id}`);
        if (typeof this._setRelations === 'function') {
          return this._setRelations(type, data);
        }
        logger.enabledLevels.debug && log.debug(`setting links for ${type}#${data.id}`);
        return Promise.each(this.getLinks(type, data), link => Promise.resolve()
          .then(() => this._load(link.value.target, data[link.name]))
          .then(rData => {
            if (link.value.hasMany) {
              rData[link.value.as] = rData[link.value.as] || [];
              rData[link.value.as].push(data.id);
            } else if (link.value.hasOne) {
              rData[link.value.as] = data.id;
            }
            return this.update(type, rData, {cascade: false});
          })
        );
      })
      .return(data)
      .asCallback(cb);
  }

  omitLinks(type, data) {
    const links = this.getLinks(type, data);
    return links.length ? _.omit(data, _.map(links, 'name')) : data;
  }

  load(type, id, opt, cb) {
    if (typeof cb === 'undefined' && typeof opt === 'function') {
      cb = opt;
      opt = null;
    }
    opt = opt && _.clone(opt) || {};
    opt.links = typeof opt.links === 'undefined' || opt.links;
    return Promise.resolve()
      .then(() => {
        if (typeof id !== 'string') {
          throw new AnystoreError.MissingArgument('missing id');
        }
        logger.enabledLevels.debug && log.debug(`loading ${type}#${id}`);
        return this._load(type, id, opt);
      })
      .asCallback(cb);
  }

  create(type, data, opt, cb) {
    if (typeof cb === 'undefined' && typeof opt === 'function') {
      cb = opt;
      opt = null;
    }
    opt = opt && _.clone(opt) || {};
    opt.cascade = typeof opt.cascade === 'undefined' || opt.cascade;
    data = _.cloneDeep(data);
    logger.enabledLevels.debug && log.debug(`creating new ${type}`);
    return Promise.resolve()
      .then(() => this.validate(type, data, 'create'))
      .then(() => {
        data.id = data.id || uuid.v4();
      })
      .then(() => this._create(type, data, opt))
      .then(newData => Promise.resolve()
        .then(() => {
          if (!opt.cascade) {
            return;
          }
          return this.setRelations(type, newData);
        })
        .return(newData)
      )
      .asCallback(cb);
  }

  update(type, data, opt, cb) {
    if (typeof cb === 'undefined' && typeof opt === 'function') {
      cb = opt;
      opt = null;
    }
    opt = opt && _.clone(opt) || {};
    opt.cascade = typeof opt.cascade === 'undefined' || opt.cascade;
    return Promise.resolve()
      .then(() => this.validate(type, data, 'update'))
      .then(() => {
        logger.enabledLevels.debug && log.debug(`updating ${type}#${data.id}`);
        if (typeof this._update !== 'function') {
          return Promise.resolve()
            .then(() => this.delete(type, data.id, opt))
            .then(existingData => this.create(type, _.extend({}, existingData, data), opt));
        }
        return Promise.resolve()
          .then(() => {
            if (!opt.cascade) {
              return;
            }
            return Promise.resolve()
              .then(() => this._load(type, data.id))
              .then(existingData => this.unsetRelations(type, existingData));
          })
          .then(() => this._update(type, data, opt))
          .then(newData => {
            if (!opt.cascade) {
              return;
            }
            return this.setRelations(type, newData);
          });
      })
      .asCallback(cb);
  }

  delete(type, id, opt, cb) {
    if (typeof cb === 'undefined' && typeof opt === 'function') {
      cb = opt;
      opt = null;
    }
    opt = opt && _.clone(opt) || {};
    opt.cascade = typeof opt.cascade === 'undefined' || opt.cascade;
    return Promise.resolve()
      .then(() => {
        logger.enabledLevels.debug && log.debug(`deleting ${type}#${id}`);
        if (typeof id !== 'string') {
          throw new AnystoreError.MissingArgument('missing id');
        }
        if (!opt.cascade) {
          return;
        }
        return Promise.resolve()
          .then(() => this._load(type, id))
          .then(existingData => this.unsetRelations(type, existingData));
      })
      .then(() => this._delete(type, id, opt))
      .asCallback(cb);
  }

  findByIndex(type, indexName, indexValue, cb) {
    return Promise.resolve()
      .then(() => {
        logger.enabledLevels.debug && log.debug(`searching ${type} items with ${indexName}: "${indexValue}"`);
        if (typeof this._findByIndex === 'function') {
          return this._findByIndex(type, indexName, indexValue);
        }
        return this.listOfType(type)
          .filter(item => {
            if (Array.isArray(indexName)) {
              return indexName.reduce((found, key, index) => found && item[key] === indexValue[index], true);
            } else {
              return item[indexName] === indexValue;
            }
          });
      })
      .asCallback(cb);
  }

  listOfType(type, opt, cb) {
    if (typeof cb === 'undefined' && typeof opt === 'function') {
      cb = opt;
      opt = null;
    }
    opt = opt && _.clone(opt) || {};
    opt.links = typeof opt.links === 'undefined' || opt.links;
    return Promise.resolve()
      .then(() => {
        if (typeof type !== 'string') {
          throw new AnystoreError.MissingArgument('missing type');
        }
        logger.enabledLevels.debug && log.debug(`loading ${type} items`);
        return this._listOfType(type, opt);
      })
      .asCallback(cb);
  }

  reset(cb) {
    return Promise.resolve()
      .then(() => {
        if (typeof this._reset === 'function') {
          return this._reset();
        }
        const types = _.map(this.schemas, 'id');
        return Promise.each(types, type => this.listOfType(type)
          .then(list => Promise.map(list, item => this.delete(type, item.id, {cascade: false})))
        );
      })
      .asCallback(cb);
  }

  dump(cb) {
    return Promise.resolve()
      .then(() => {
        const types = _.map(this.schemas, 'id');
        return Promise
          .reduce(types, (dump, type) => this.listOfType(type).then(list => {
            dump[type] = list;
            return dump;
          }), {});
      })
      .asCallback(cb);
  }
}

for (const fnName of ['create', 'load', 'delete']) {
  Anystore.prototype[`_${fnName}`] = function() {
    throw new AnystoreError.NotImplemented(`method "${fnName}" not implemented`);
  };
}

Anystore.Error = AnystoreError;
Anystore.patterns = {
  id: '^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$'
};
Anystore.idSchema = {
  type: 'string',
  pattern: Anystore.patterns.id
};
Anystore.stores = {};
Anystore.toStoreName = name => {
  const parts = name.match(/^(.*)(-)?[Ss]tore(.js)?$/);
  if (!parts || !parts[1]) {
    return;
  }
  name = parts[1].substring(0, 1).toUpperCase() + _.camelCase(parts[1].substring(1)) + 'Store';
  return name;
};
Anystore.addStore = (store, name) => {
  name = name || Anystore.toStoreName(store.name);
  Anystore.stores[name] = store;
  logger.enabledLevels.info && log.info(`${name} registered`);
};

const loadDefaultStores = () => {
  const storesDir = path.join(__dirname, 'stores');
  const defaultStoreFiles = fs.readdirSync(storesDir);
  defaultStoreFiles.forEach(defaultStoreFile => {
    const storeName = Anystore.toStoreName(defaultStoreFile);
    logger.enabledLevels.info && log.info(`loading ${storeName}`);
    try {
      const store = require(path.join(storesDir, defaultStoreFile));
      Anystore.addStore(store, storeName);
    } catch (err) {
      logger.enabledLevels.warn && log.warn(`error while loading store ${storeName} :`, err.toString());
    }
  });
};

exports = module.exports = Anystore;

loadDefaultStores();