Source: lib/EntityManager.js

'use strict';

const messages = require('./message');
const error = require('./error');
const binding = require('./binding');
const util = require('./util');
const query = require('./query');

const UserFactory = require('./binding/UserFactory');
const Metadata = require('./util/Metadata');
const Message = require('./connector/Message');
const BloomFilter = require('./caching/BloomFilter');
const deorecated = require('./util/deprecated');

const StatusCode = Message.StatusCode;
const DB_PREFIX = '/db/';

/**
 * @alias EntityManager
 * @extends util.Lockable
 */
class EntityManager extends util.Lockable {
  /**
   * Determine whether the entity manager is open.
   * true until the entity manager has been closed
   * @type boolean
   * @readonly
   */
  get isOpen() {
    return !!this.connection;
  }

  /**
   * The authentication token if the user is logged in currently
   * @type string
   */
  get token() {
    return this.tokenStorage.token;
  }

  /**
   * Whether caching is disabled
   * @type boolean
   * @readonly
   */
  get isCachingDisabled() {
    return !this.bloomFilter;
  }

  /**
   * Returns true if the device token is already registered, otherwise false.
   * @type boolean
   * @readonly
   */
  get isDeviceRegistered() {
    return !!this.deviceMe;
  }

  /**
   * The authentication token if the user is logged in currently
   * @param {string} value
   */
  set token(value) {
    this.tokenStorage.update(value);
  }

  /**
   * @param {EntityManagerFactory} entityManagerFactory The factory which of this entityManager instance
   */
  constructor(entityManagerFactory) {
    super();

    /**
     * Log messages can created by calling log directly as function, with a specific log level or with the helper
     * methods, which a members of the log method.
     *
     * Logs will be filtered by the client logger and the before they persisted. The default log level is
     * 'info' therefore all log messages below the given message aren't persisted.
     *
     * Examples:
     * <pre class="prettyprint">
     // default log level ist info
     db.log('test message %s', 'my string');
     // info: test message my string

     // pass a explicit log level as the first argument, one of ('trace', 'debug', 'info', 'warn', 'error')
     db.log('warn', 'test message %d', 123);
     // warn: test message 123

     // debug log level will not be persisted by default, since the default logging level is info
     db.log('debug', 'test message %j', {number: 123}, {});
     // debug: test message {"number":123}
     // data = {}

     // One additional json object can be provided, which will be persisted together with the log entry
     db.log('info', 'test message %s, %s', 'first', 'second', {number: 123});
     // info: test message first, second
     // data = {number: 123}

     //use the log level helper
     db.log.info('test message', 'first', 'second', {number: 123});
     // info: test message first second
     // data = {number: 123}

     //change the default log level to trace, i.e. all log levels will be persisted, note that the log level can be
     //additionally configured in the baqend
     db.log.level = 'trace';

     //trace will be persisted now
     db.log.trace('test message', 'first', 'second', {number: 123});
     // info: test message first second
     // data = {number: 123}
     * </pre>
     *
     * @type util.Logger
     * @readonly
     */
    this.log = util.Logger.create(this);

    /**
     * The connector used for requests
     * @type connector.Connector
     * @private
     */
    this.connection = null;

    /**
     * All managed and cached entity instances
     * @type Map<String,binding.Entity>
     * @private
     */
    this.entities = null;

    /**
     * @type EntityManagerFactory
     * @readonly
     */
    this.entityManagerFactory = entityManagerFactory;

    /**
     * @type metamodel.Metamodel
     * @readonly
     */
    this.metamodel = entityManagerFactory.metamodel;

    /**
     * @type util.Code
     * @readonly
     */
    this.code = entityManagerFactory.code;

    /**
     * @type util.Modules
     * @readonly
     */
    this.modules = null;

    /**
     * The current logged in user object
     * @type (model.User|null)
     * @readonly
     */
    this.me = null;

    /**
     * The current registered device object
     * @type (model.Device|null)
     * @readonly
     */
    this.deviceMe = null;

    /**
     * Returns the tokenStorage which will be used to authorize all requests.
     * @type {util.TokenStorage}
     * @readonly
     */
    this.tokenStorage = null;

    /**
     * @type {caching.BloomFilter}
     * @readonly
     */
    this.bloomFilter = null;

    /**
     * Set of object ids that were revalidated after the Bloom filter was loaded.
     */
    this.cacheWhiteList = null;

    /**
     * Set of object ids that were updated but are not yet included in the bloom filter.
     * This set essentially implements revalidation by side effect which does not work in Chrome.
     */
    this.cacheBlackList = null;

    /**
     * Bloom filter refresh interval in seconds.
     *
     * @type {number}
     * @readonly
     */
    this.bloomFilterRefresh = 60;

    /**
     * Bloom filter refresh Promise
     *
     */
    this.bloomFilterLock = new util.Lockable();
  }

  /**
   * Connects this entityManager, used for synchronous and asynchronous initialization
   * @param {connector.Connector} connector
   * @param {Object} connectData
   * @param {util.TokenStorage} tokenStorage The used tokenStorage for token persistence
   * @return {void}
   */
  connected(connector, connectData, tokenStorage) {
    this.connection = connector;
    this.tokenStorage = tokenStorage;
    this.bloomFilterRefresh = this.entityManagerFactory.staleness;
    this.entities = {};

    this.File = binding.FileFactory.create(this);
    this._createObjectFactory(this.metamodel.embeddables);
    this._createObjectFactory(this.metamodel.entities);

    this.transaction = {}; // TODO: implement this
    this.modules = new util.Modules(this, connector);

    if (connectData) {
      if (connectData.device) {
        this.updateDevice(connectData.device);
      }

      if (connectData.user && tokenStorage.token) {
        this._updateUser(connectData.user, true);
      }

      if (this.bloomFilterRefresh > 0 && connectData.bloomFilter && util.atob && !util.isNode) {
        this.updateBloomFilter(connectData.bloomFilter);
      }
    }
  }

  /**
   * @param {metamodel.ManagedType[]} types
   * @return {binding.ManagedFactory}
   * @private
   */
  _createObjectFactory(types) {
    Object.keys(types).forEach((ref) => {
      const type = this.metamodel.managedType(ref);
      const name = type.name;

      if (this[name]) {
        type.typeConstructor = this[name];
        Object.defineProperty(this, name, {
          value: type.createObjectFactory(this),
        });
      } else {
        Object.defineProperty(this, name, {
          get() {
            Object.defineProperty(this, name, {
              value: type.createObjectFactory(this),
            });

            return this[name];
          },
          set(typeConstructor) {
            type.typeConstructor = typeConstructor;
          },
          configurable: true,
        });
      }
    }, this);
  }

  send(mesage, ignoreCredentialError) {
    const msg = mesage;
    msg.tokenStorage = this.tokenStorage;
    let result = this.connection.send(msg);
    if (!ignoreCredentialError) {
      result = result.catch((e) => {
        if (e.status === StatusCode.BAD_CREDENTIALS) {
          this._logout();
        }
        throw e;
      });
    }
    return result;
  }

  /**
   * Get an instance whose state may be lazily fetched
   *
   * If the requested instance does not exist in the database, the
   * EntityNotFoundError is thrown when the instance state is first accessed.
   * The application should not expect that the instance state will be available upon detachment,
   * unless it was accessed by the application while the entity manager was open.
   *
   * @param {(Class<binding.Entity>|string)} entityClass
   * @param {string=} key
   * @return {binding.Entity}
   */
  getReference(entityClass, key) {
    let id;
    let type;
    if (key) {
      const keyAsStr = key;
      type = this.metamodel.entity(entityClass);
      if (keyAsStr.indexOf(DB_PREFIX) === 0) {
        id = keyAsStr;
      } else {
        id = type.ref + '/' + encodeURIComponent(keyAsStr);
      }
    } else if (typeof entityClass === 'string') {
      const keyIndex = entityClass.indexOf('/', DB_PREFIX.length); // skip /db/
      if (keyIndex !== -1) {
        id = entityClass;
      }
      type = this.metamodel.entity(keyIndex === -1 ? entityClass : id.substring(0, keyIndex));
    } else {
      type = this.metamodel.entity(entityClass);
    }

    let entity = this.entities[id];
    if (!entity) {
      entity = type.create();
      const metadata = Metadata.get(entity);
      if (id) {
        metadata.id = id;
      }
      metadata.setUnavailable();
      this._attach(entity);
    }

    return entity;
  }

  /**
   * Creates an instance of {@link query.Builder<T>} for query creation and execution
   *
   * The query results are instances of the resultClass argument.
   *
   * @alias EntityManager.prototype.createQueryBuilder<T>
   * @param {Class<T>=} resultClass - the type of the query result
   * @return {query.Builder<T>} A query builder to create one ore more queries for the specified class
   */
  createQueryBuilder(resultClass) {
    return new query.Builder(this, resultClass);
  }

  /**
   * Clear the persistence context, causing all managed entities to become detached
   *
   * Changes made to entities that have not been flushed to the database will not be persisted.
   *
   * @return {void}
   */
  clear() {
    this.entities = {};
  }

  /**
   * Close an application-managed entity manager
   *
   * After the close method has been invoked, all methods on the EntityManager instance
   * and any Query and TypedQuery objects obtained from it will throw the IllegalStateError
   * except for transaction, and isOpen (which will return false). If this method
   * is called when the entity manager is associated with an active transaction,
   * the persistence context remains managed until the transaction completes.
   *
   * @return {void}
   */
  close() {
    this.connection = null;

    return this.clear();
  }

  /**
   * Check if the instance is a managed entity instance belonging to the current persistence context
   *
   * @param {binding.Entity} entity - entity instance
   * @return {boolean} boolean indicating if entity is in persistence context
   */
  contains(entity) {
    return !!entity && this.entities[entity.id] === entity;
  }

  /**
   * Check if an object with the id from the given entity is already attached
   *
   * @param {binding.Entity} entity - entity instance
   * @return {boolean} boolean indicating if entity with same id is attached
   */
  containsById(entity) {
    return !!(entity && this.entities[entity.id]);
  }

  /**
   * Remove the given entity from the persistence context, causing a managed entity to become detached
   *
   * Unflushed changes made to the entity if any (including removal of the entity),
   * will not be synchronized to the database. Entities which previously referenced the detached entity will continue
   * to reference it.
   *
   * @param {binding.Entity} entity The entity instance to detach.
   * @return {Promise<binding.Entity>}
   */
  detach(entity) {
    const state = Metadata.get(entity);
    return state.withLock(() => {
      this.removeReference(entity);
      return Promise.resolve(entity);
    });
  }

  /**
   * Resolve the depth by loading the referenced objects of the given entity
   *
   * @param {binding.Entity} entity - entity instance
   * @param {Object} [options] The load options
   * @return {Promise<binding.Entity>}
   */
  resolveDepth(entity, options) {
    if (!options || !options.depth) {
      return Promise.resolve(entity);
    }

    options.resolved = options.resolved || [];

    const promises = [];
    const subOptions = Object.assign({}, options, {
      depth: options.depth === true ? true : options.depth - 1,
    });
    this.getSubEntities(entity, 1).forEach((subEntity) => {
      if (subEntity !== null && options.resolved.indexOf(subEntity) === -1) {
        options.resolved.push(subEntity);
        promises.push(this.load(subEntity.id, null, subOptions));
      }
    });

    return Promise.all(promises).then(() => entity);
  }

  /**
   * Search for an entity of the specified oid
   *
   * If the entity instance is contained in the persistence context, it is returned from there.
   *
   * @param {(Class<binding.Entity>|string)} entityClass - entity class
   * @param {String} oid - Object ID
   * @param {Object} [options] The load options.
   * @return {Promise<binding.Entity>} the loaded entity or null
   */
  load(entityClass, oid, options) {
    const opt = options || {};
    const entity = this.getReference(entityClass, oid);
    const state = Metadata.get(entity);

    if (!opt.refresh && opt.local && state.isAvailable) {
      return this.resolveDepth(entity, opt);
    }

    const msg = new messages.GetObject(state.bucket, state.key);

    this.ensureCacheHeader(entity.id, msg, opt.refresh);

    return this.send(msg).then((response) => {
      // refresh object if loaded older version from cache
      // chrome doesn't using cache when ifNoneMatch is set
      if (entity.version > response.entity.version) {
        opt.refresh = true;
        return this.load(entityClass, oid, opt);
      }

      this.addToWhiteList(response.entity.id);

      if (response.status !== StatusCode.NOT_MODIFIED) {
        state.setJson(response.entity, { persisting: true });
      }

      return this.resolveDepth(entity, opt);
    }, (e) => {
      if (e.status === StatusCode.OBJECT_NOT_FOUND) {
        this.removeReference(entity);
        state.setRemoved();
        return null;
      }

      throw e;
    });
  }

  /**
   * @param {binding.Entity} entity
   * @param {Object} options
   * @return {Promise<binding.Entity>}
   */
  insert(entity, options) {
    const opt = options || {};
    let isNew;

    return this._save(entity, opt, (state, json) => {
      if (state.version) {
        throw new error.PersistentError('Existing objects can\'t be inserted.');
      }

      isNew = !state.id;

      return new messages.CreateObject(state.bucket, json);
    }).then((val) => {
      if (isNew) {
        this._attach(entity);
      }

      return val;
    });
  }

  /**
   * @param {binding.Entity} entity
   * @param {Object} options
   * @return {Promise<binding.Entity>}
   */
  update(entity, options) {
    const opt = options || {};

    return this._save(entity, opt, (state, json) => {
      if (!state.version) {
        throw new error.PersistentError('New objects can\'t be inserted.');
      }

      if (opt.force) {
        delete json.version;
        return new messages.ReplaceObject(state.bucket, state.key, json)
          .ifMatch('*');
      }

      return new messages.ReplaceObject(state.bucket, state.key, json)
        .ifMatch(state.version);
    });
  }

  /**
   * @param {binding.Entity} entity
   * @param {Object} options The save options
   * @param {boolean=} withoutLock Set true to save the entity without locking
   * @return {Promise<binding.Entity>}
   */
  save(entity, options, withoutLock) {
    const opt = options || {};

    const msgFactory = (state, json) => {
      if (opt.force) {
        if (!state.id) {
          throw new error.PersistentError('New special objects can\'t be forcedly saved.');
        }

        delete json.version;
        return new messages.ReplaceObject(state.bucket, state.key, json);
      }

      if (state.version) {
        return new messages.ReplaceObject(state.bucket, state.key, json)
          .ifMatch(state.version);
      }

      return new messages.CreateObject(state.bucket, json);
    };

    return withoutLock ? this._locklessSave(entity, opt, msgFactory) : this._save(entity, opt, msgFactory);
  }

  /**
   * @param {binding.Entity} entity
   * @param {Function} cb pre-safe callback
   * @return {Promise<binding.Entity>}
   */
  optimisticSave(entity, cb) {
    return Metadata.get(entity).withLock(() => this._optimisticSave(entity, cb));
  }

  /**
   * @param {binding.Entity} entity
   * @param {Function} cb pre-safe callback
   * @return {Promise<binding.Entity>}
   * @private
   */
  _optimisticSave(entity, cb) {
    let abort = false;
    const abortFn = () => {
      abort = true;
    };
    const promise = Promise.resolve(cb(entity, abortFn));

    if (abort) {
      return Promise.resolve(entity);
    }

    return promise.then(() => (
      this.save(entity, {}, true)
        .catch((e) => {
          if (e.status === 412) {
            return this.refresh(entity, {})
              .then(() => this._optimisticSave(entity, cb));
          }

          throw e;
        })
    ));
  }

  /**
   * Save the object state without locking
   * @param {binding.Entity} entity
   * @param {Object} options
   * @param {Function} msgFactory
   * @return {Promise.<binding.Entity>}
   * @private
   */
  _locklessSave(entity, options, msgFactory) {
    this.attach(entity);
    const state = Metadata.get(entity);
    let refPromises;

    let json;
    if (state.isAvailable) {
      // getting json will check all collections changes, therefore we must do it before proofing the dirty state
      json = state.getJson({
        persisting: true,
      });
    }

    if (state.isDirty) {
      if (!options.refresh) {
        state.setPersistent();
      }

      const sendPromise = this.send(msgFactory(state, json)).then((response) => {
        if (state.id && state.id !== response.entity.id) {
          this.removeReference(entity);
          state.id = response.entity.id;
          this._attach(entity);
        }

        state.setJson(response.entity, {
          persisting: options.refresh,
          onlyMetadata: !options.refresh,
        });
        return entity;
      }, (e) => {
        if (e.status === StatusCode.OBJECT_NOT_FOUND) {
          this.removeReference(entity);
          state.setRemoved();
          return null;
        }

        state.setDirty();
        throw e;
      });

      refPromises = [sendPromise];
    } else {
      refPromises = [Promise.resolve(entity)];
    }

    const subOptions = Object.assign({}, options);
    subOptions.depth = 0;
    this.getSubEntities(entity, options.depth).forEach((sub) => {
      refPromises.push(this._save(sub, subOptions, msgFactory));
    });

    return Promise.all(refPromises).then(() => entity);
  }

  /**
   * Save and lock the object state
   * @param {binding.Entity} entity
   * @param {Object} options
   * @param {Function} msgFactory
   * @return {Promise.<binding.Entity>}
   * @private
   */
  _save(entity, options, msgFactory) {
    this.ensureBloomFilterFreshness();

    const state = Metadata.get(entity);
    if (state.version) {
      this.addToBlackList(entity.id);
    }

    return state.withLock(() => this._locklessSave(entity, options, msgFactory));
  }

  /**
   * Returns all referenced sub entities for the given depth and root entity
   * @param {binding.Entity} entity
   * @param {boolean|number} depth
   * @param {binding.Entity[]} [resolved]
   * @param {binding.Entity=} initialEntity
   * @return {binding.Entity[]}
   */
  getSubEntities(entity, depth, resolved, initialEntity) {
    let resolv = resolved || [];
    if (!depth) {
      return resolv;
    }

    const obj = initialEntity || entity;
    const state = Metadata.get(entity);
    const iter = state.type.references();
    for (let item = iter.next(); !item.done; item = iter.next()) {
      const value = item.value;
      const subEntities = this.getSubEntitiesByPath(entity, value.path);
      for (let i = 0, len = subEntities.length; i < len; i += 1) {
        const subEntity = subEntities[i];
        if (resolv.indexOf(subEntity) === -1 && subEntity !== obj) {
          resolv.push(subEntity);
          resolv = this.getSubEntities(subEntity, depth === true ? depth : depth - 1, resolv, obj);
        }
      }
    }

    return resolv;
  }

  /**
   * Returns all referenced one level sub entities for the given path
   * @param {binding.Entity} entity
   * @param {Array<string>} path
   * @return {binding.Entity[]}
   */
  getSubEntitiesByPath(entity, path) {
    let subEntities = [entity];

    path.forEach((attributeName) => {
      const tmpSubEntities = [];
      subEntities.forEach((subEntity) => {
        const curEntity = subEntity[attributeName];
        if (!curEntity) {
          return;
        }

        const attribute = this.metamodel.managedType(subEntity.constructor).getAttribute(attributeName);
        if (attribute.isCollection) {
          const iter = curEntity.entries();
          for (let item = iter.next(); !item.done; item = iter.next()) {
            const entry = item.value;
            tmpSubEntities.push(entry[1]);
            if (attribute.keyType && attribute.keyType.isEntity) {
              tmpSubEntities.push(entry[0]);
            }
          }
        } else {
          tmpSubEntities.push(curEntity);
        }
      });
      subEntities = tmpSubEntities;
    });

    return subEntities;
  }

  /**
   * Delete the entity instance.
   * @param {binding.Entity} entity
   * @param {Object} options The delete options
   * @return {Promise<binding.Entity>}
   */
  'delete'(entity, options) {
    const opt = options || {};

    this.attach(entity);
    const state = Metadata.get(entity);

    return state.withLock(() => {
      if (!state.version && !opt.force) {
        throw new error.IllegalEntityError(entity);
      }

      const msg = new messages.DeleteObject(state.bucket, state.key);

      this.addToBlackList(entity.id);

      if (!opt.force) {
        msg.ifMatch(state.version);
      }

      const refPromises = [this.send(msg).then(() => {
        this.removeReference(entity);
        state.setRemoved();
        return entity;
      })];

      const subOptions = Object.assign({}, opt);
      subOptions.depth = 0;
      this.getSubEntities(entity, opt.depth).forEach((sub) => {
        refPromises.push(this.delete(sub, subOptions));
      });

      return Promise.all(refPromises).then(() => entity);
    });
  }

  /**
   * Synchronize the persistence context to the underlying database.
   *
   * @return {Promise<*>}
   */
  flush() {
    // TODO: implement this
  }

  /**
   * Make an instance managed and persistent.
   * @param {binding.Entity} entity - entity instance
   * @return {void}
   */
  persist(entity) {
    this.attach(entity);
  }

  /**
   * Refresh the state of the instance from the database, overwriting changes made to the entity, if any.
   * @param {binding.Entity} entity - entity instance
   * @param {Object} options The refresh options
   * @return {Promise<binding.Entity>}
   */
  refresh(entity, options) {
    const opt = options || {};
    opt.refresh = true;

    return this.load(entity.id, null, opt);
  }

  /**
   * Attach the instance to this database context, if it is not already attached
   * @param {binding.Entity} entity The entity to attach
   * @return {void}
   */
  attach(entity) {
    if (!this.contains(entity)) {
      const type = this.metamodel.entity(entity.constructor);
      if (!type) {
        throw new error.IllegalEntityError(entity);
      }

      if (this.containsById(entity)) {
        throw new error.EntityExistsError(entity);
      }

      this._attach(entity);
    }
  }

  _attach(entity) {
    const metadata = Metadata.get(entity);
    if (metadata.isAttached) {
      if (metadata.db !== this) {
        throw new error.EntityExistsError(entity);
      }
    } else {
      metadata.db = this;
    }

    if (!metadata.id) {
      if (metadata.type.name !== 'User' && metadata.type.name !== 'Role' && metadata.type.name !== 'logs.AppLog') {
        metadata.id = DB_PREFIX + metadata.type.name + '/' + util.uuid();
      }
    }

    if (metadata.id) {
      this.entities[metadata.id] = entity;
    }
  }

  removeReference(entity) {
    const state = Metadata.get(entity);
    if (!state) {
      throw new error.IllegalEntityError(entity);
    }

    delete this.entities[state.id];
  }

  register(user, password, loginOption) {
    const login = loginOption > UserFactory.LoginOption.NO_LOGIN;
    if (this.me && login) {
      throw new error.PersistentError('User is already logged in.');
    }

    return this.withLock(() => {
      const msg = new messages.Register({ user, password, login });
      return this._userRequest(msg, loginOption);
    });
  }

  login(username, password, loginOption) {
    if (this.me) {
      throw new error.PersistentError('User is already logged in.');
    }

    return this.withLock(() => {
      const msg = new messages.Login({ username, password });
      return this._userRequest(msg, loginOption);
    });
  }

  logout() {
    return this.withLock(() => this.send(new messages.Logout()).then(this._logout.bind(this)));
  }

  loginWithOAuth(provider, clientID, options) {
    if (this.me) {
      throw new error.PersistentError('User is already logged in.');
    }

    const opt = Object.assign({
      title: 'Login with ' + provider,
      timeout: 5 * 60 * 1000,
      state: {},
      loginOption: true,
    }, options);

    if (opt.redirect) {
      Object.assign(opt.state, { redirect: opt.redirect, loginOption: opt.loginOption });
    }

    let msg;
    if (Message[provider + 'OAuth']) {
      msg = new Message[provider + 'OAuth'](clientID, opt.scope, JSON.stringify(opt.state));
      msg.addRedirectOrigin(this.connection.origin + this.connection.basePath);
    } else {
      throw new Error('OAuth provider ' + provider + ' not supported.');
    }

    const windowOptions = { width: opt.width, height: opt.height };
    if (opt.redirect) {
      // use oauth via redirect by opening the login in the same window
      // for app wrappers we need to open the system browser
      const isBrowser = document.URL.indexOf('http://') !== -1 || document.URL.indexOf('https://') !== -1;
      this.openOAuthWindow(msg.request.path, isBrowser ? '_self' : '_system', windowOptions);
      return new Promise(() => {});
    }

    const req = this._userRequest(msg, opt.loginOption);
    this.openOAuthWindow(msg.request.path, opt.title, windowOptions);

    return new Promise((resolve, reject) => {
      const timeout = setTimeout(() => {
        reject(new error.PersistentError('OAuth login timeout.'));
      }, opt.timeout);

      req.then(resolve, reject).then(() => {
        clearTimeout(timeout);
      });
    });
  }

  /**
   * Opens a new window use for OAuth logins
   * @param {string} url The url to open
   * @param {string} targetOrTitle The target of the window, or the title of the popup
   * @param {object} options Additional window options
   * @return {void}
   */
  openOAuthWindow(url, targetOrTitle, options) {
    const str = Object.keys(options)
      .filter(key => options[key] !== undefined)
      .map(key => key + '=' + options[key])
      .join(',');

    open(url, targetOrTitle, str); // eslint-disable-line no-restricted-globals
  }

  renew(loginOption) {
    return this.withLock(() => {
      const msg = new messages.Me();
      return this._userRequest(msg, loginOption);
    });
  }

  newPassword(username, password, newPassword) {
    return this.withLock(() => {
      const msg = new messages.NewPassword({ username, password, newPassword });

      return this.send(msg, true).then(response => this._updateUser(response.entity));
    });
  }

  newPasswordWithToken(token, newPassword, loginOption) {
    return this.withLock(() => (
      this._userRequest(new messages.NewPassword({ token, newPassword }), loginOption)
    ));
  }

  resetPassword(username) {
    return this.send(new messages.ResetPassword({ username }));
  }

  changeUsername(username, newUsername, password) {
    return this.send(new messages.ChangeUsername({ username, newUsername, password }));
  }

  _updateUser(obj, updateMe) {
    const user = this.getReference(obj.id);
    const metadata = Metadata.get(user);
    metadata.setJson(obj, { persisting: true });

    if (updateMe) {
      this.me = user;
    }

    return user;
  }

  _logout() {
    this.me = null;
    this.token = null;
  }

  _userRequest(msg, loginOption) {
    const opt = loginOption === undefined ? true : loginOption;
    const login = opt > UserFactory.LoginOption.NO_LOGIN;
    if (login) {
      this.tokenStorage.temporary = opt < UserFactory.LoginOption.PERSIST_LOGIN;
    }

    return this.send(msg, !login)
      .then(
        response => (response.entity ? this._updateUser(response.entity, login) : null),
        (e) => {
          if (e.status === StatusCode.OBJECT_NOT_FOUND) {
            if (login) {
              this._logout();
            }
            return null;
          }

          throw e;
        }
      );
  }

  /**
   * @param {string} devicetype The OS of the device (IOS/Android)
   * @param {object} subscription WebPush subscription
   * @param {model.Device} device
   * @return {Promise<model.Device>}
   */
  registerDevice(devicetype, subscription, device) {
    const msg = new messages.DeviceRegister({ devicetype, subscription, device });

    msg.withCredentials = true;
    return this.send(msg)
      .then(response => this.updateDevice(response.entity));
  }

  updateDevice(obj) {
    const device = this.getReference(obj.id);
    const metadata = Metadata.get(device);
    metadata.setJson(obj, { persisting: true });

    this.deviceMe = device;
    return device;
  }

  checkDeviceRegistration() {
    return this.send(new messages.DeviceRegistered())
      .then(() => {
        this.isDeviceRegistered = true;
        return true;
      }, (e) => {
        if (e.status === StatusCode.OBJECT_NOT_FOUND) {
          this.isDeviceRegistered = false;
          return false;
        }

        throw e;
      });
  }

  pushDevice(pushMessage) {
    return this.send(new messages.DevicePush(pushMessage));
  }

  /**
   * The given entity will be checked by the validation code of the entity type.
   *
   * @param {binding.Entity} entity
   * @return {util.ValidationResult} result
   */
  validate(entity) {
    const type = Metadata.get(entity).type;

    const result = new util.ValidationResult();
    const iter = type.attributes();
    for (let item = iter.next(); !item.done; item = iter.next()) {
      const validate = new util.Validator(item.value.name, entity);
      result.fields[validate.key] = validate;
    }

    const validationCode = type.validationCode;
    if (validationCode) {
      validationCode(result.fields);
    }

    return result;
  }

  /**
   * Adds the given object id to the cacheWhiteList if needed.
   * @param {string} objectId The id to add.
   * @return {void}
   */
  addToWhiteList(objectId) {
    if (!this.isCachingDisabled) {
      if (this.bloomFilter.contains(objectId)) {
        this.cacheWhiteList.add(objectId);
      }
      this.cacheBlackList.delete(objectId);
    }
  }

  /**
   * Adds the given object id to the cacheBlackList if needed.
   * @param {string} objectId The id to add.
   * @return {void}
   */
  addToBlackList(objectId) {
    if (!this.isCachingDisabled) {
      if (!this.bloomFilter.contains(objectId)) {
        this.cacheBlackList.add(objectId);
      }
      this.cacheWhiteList.delete(objectId);
    }
  }

  refreshBloomFilter() {
    if (this.isCachingDisabled) {
      return Promise.resolve();
    }

    const msg = new messages.GetBloomFilter();
    msg.noCache();
    return this.send(msg).then((response) => {
      this.updateBloomFilter(response.entity);
      return this.bloomFilter;
    });
  }

  updateBloomFilter(bloomFilter) {
    this.bloomFilter = new BloomFilter(bloomFilter);
    this.cacheWhiteList = new Set();
    this.cacheBlackList = new Set();
  }

  /**
   * Checks the freshness of the bloom filter and does a reload if necessary
   * @return {void}
   */
  ensureBloomFilterFreshness() {
    if (this.isCachingDisabled) {
      return;
    }

    const now = new Date().getTime();
    const refreshRate = this.bloomFilterRefresh * 1000;

    if (this.bloomFilterLock.isReady && now - this.bloomFilter.creation > refreshRate) {
      this.bloomFilterLock.withLock(() => this.refreshBloomFilter());
    }
  }

  /**
   * Checks for a given id, if revalidation is required, the resource is stale or caching was disabled
   * @param {string} id The object id to check
   * @return {boolean} Indicates if the resource must be revalidated
   */
  mustRevalidate(id) {
    if (util.isNode) {
      return false;
    }

    this.ensureBloomFilterFreshness();

    let refresh = this.isCachingDisabled || !this.bloomFilterLock.isReady;
    refresh = refresh || (
      !this.cacheWhiteList.has(id)
      && (this.cacheBlackList.has(id) || this.bloomFilter.contains(id))
    );
    return refresh;
  }

  /**
   * @param {string} id To check the bloom filter
   * @param {connector.Message} message To attach the headers
   * @param {boolean} refresh To force the reload headers
   * @return {void}
   */
  ensureCacheHeader(id, message, refresh) {
    const noCache = refresh || this.mustRevalidate(id);

    if (noCache) {
      message.noCache();
    }
  }

  /**
   * Creates a absolute url for the given relative one
   * @param {string} relativePath the relative url
   * @param {boolean=} authorize indicates if authorization credentials should be generated and be attached to the url
   * @return {string} a absolute url wich is optionaly signed with a resource token which authenticates the currently
   * logged in user
   */
  createURL(relativePath, authorize) {
    let path = this.connection.basePath + relativePath;

    let append = false;
    if (authorize && this.me) {
      path = this.tokenStorage.signPath(path);
      append = true;
    } else {
      path = path.split('/').map(encodeURIComponent).join('/');
    }

    if (this.mustRevalidate(relativePath)) {
      path = path + (append ? '&' : '?') + 'BCB';
    }

    return this.connection.origin + path;
  }

  /**
   * Requests a perpetual token for the given user
   *
   * Only users with the admin role are allowed to request an API token.
   *
   * @param {(Class<binding.Entity>|Class<binding.Managed>)} entityClass
   * @param {binding.User|String} user The user object or id of the user object
   * @return {Promise<*>}
   */
  requestAPIToken(entityClass, user) {
    const userObj = this._getUserReference(entityClass, user);

    const msg = new messages.UserToken(userObj.key);
    return this.send(msg).then(resp => resp.entity);
  }

  /**
   * Revoke all created tokens for the given user
   *
   * This method will revoke all previously issued tokens and the user must login again.
   *
   * @param {(Class<binding.Entity>|Class<binding.Managed>)} entityClass
   * @param {binding.User|String} user The user object or id of the user object
   * @return {Promise<*>}
   */
  revokeAllTokens(entityClass, user) {
    const userObj = this._getUserReference(entityClass, user);

    const msg = new messages.RevokeUserToken(userObj.key);
    return this.send(msg);
  }

  _getUserReference(entityClass, user) {
    if (typeof user === 'string') {
      return this.getReference(entityClass, user);
    }

    return user;
  }
}

/**
 * Constructor for a new List collection
 * @function
 * @name List<U>
 * @memberOf EntityManager.prototype
 * @param {...U} args Same arguments can be passed as the Array constructor takes
 * @return {Array<U>} The new created List
 */
EntityManager.prototype.List = Array;

/**
 * Constructor for a new Set collection
 * @function
 * @name Set<U>
 * @memberOf EntityManager.prototype
 * @param {Iterable<U>=} collection The initial array or collection to initialize the new Set
 * @return {Set<U>} The new created Set
 */
EntityManager.prototype.Set = Set;

/**
 * Constructor for a new Map collection
 * @function
 * @param {Iterable<*>=} collection The initial array or collection to initialize the new Map
 * @return {Map<*, *>} The new created Map
 */
EntityManager.prototype.Map = Map;

/**
 * Constructor for a new GeoPoint
 * @function
 * @param {string|number|Object|Array<number>} [latitude] A coordinate pair (latitude first), a GeoPoint like object or
 * the GeoPoint's latitude
 * @param {number=} longitude The GeoPoint's longitude
 * @return {GeoPoint} The new created GeoPoint
 */
EntityManager.prototype.GeoPoint = require('./GeoPoint');

/**
 * An User factory for user objects.
 * The User factory can be called to create new instances of users or can be used to register/login/logout users.
 * The created instances implements the {@link model.User} interface
 * @name User
 * @type binding.UserFactory
 * @memberOf EntityManager.prototype
 */

/**
 * An Role factory for role objects.
 * The Role factory can be called to create new instances of roles, later on users can be attached to roles to manage
 * the access permissions through this role
 * The created instances implements the {@link model.Role} interface
 * @name Role
 * @memberOf EntityManager.prototype
 * @type binding.EntityFactory<model.Role>
 */

/**
 * An Device factory for user objects.
 * The Device factory can be called to create new instances of devices or can be used to register, push to and
 * check registration status of devices.
 * @name Device
 * @memberOf EntityManager.prototype
 * @type binding.DeviceFactory
 */

/**
 * An Object factory for entity or embedded objects,
 * that can be accessed by the type name of the entity type.
 * An object factory can be called to create new instances of the type.
 * The created instances implements the {@link binding.Entity} or the {@link binding.Managed} interface
 * whenever the class is an entity or embedded object
 * @name [YourEntityClass: string]
 * @memberOf EntityManager.prototype
 * @type {*}
 */

/**
 * A File factory for file objects.
 * The file factory can be called to create new instances for files.
 * The created instances implements the {@link binding.File} interface
 * @name File
 * @memberOf EntityManager.prototype
 * @type binding.FileFactory
 */

deorecated(EntityManager.prototype, '_connector', 'connection');
deorecated(EntityManager.prototype, '_entities', 'entities');
deorecated(EntityManager.prototype, '_bloomFilterLock', 'bloomFilterLock');

module.exports = EntityManager;