import * as messages from './message';
import {
  DeviceFactory,
  Entity,
  EntityFactory,
  FileFactory,
  LoginOption,
  ManagedFactory,
  OAuthOptions,
  UserFactory,
} from './binding';
import { atob, Class, deprecated, isNode, JsonMap, Lockable, openWindow, uuid, } from './util';
import { Connector, Message, OAuthMessage, RestSpecification, StatusCode } from './connector';
import { BloomFilter } from './caching';
import { GeoPoint } from './GeoPoint';
import type { ConnectData, EntityManagerFactory } from './EntityManagerFactory';
import * as model from './model';
import type { Metamodel } from './metamodel';
import { EntityType, ManagedType, MapAttribute, PluralAttribute, } from './metamodel';

import {Builder, Query} from './query';
import { EntityExistsError, IllegalEntityError, PersistentError } from './error';

import {
  Code,
  Logger,
  Metadata,
  Modules,
  PushMessage,
  TokenStorage,
  ValidationResult,
  Validator,
} from './intersection';
import { appendQueryParams, CACHE_REPLACEMENT_SUPPORTED } from './connector/Message';
import { MFAError } from './error/MFAError';
import { Base64 } from './util/Base64';
import { MFAResponse } from './util/Mfa';
import * as message from "./message";

const DB_PREFIX = '/db/';

type MessageFactory = (state: Metadata, json: JsonMap) => Message;

export class EntityManager extends Lockable {
  /**
   * Constructor for a new List collection
   */
  public readonly List = Array;

  /**
   * Constructor for a new Set collection
   */
  public readonly Set = Set;

  /**
   * Constructor for a new Map collection
   */
  public readonly Map = Map;

  /**
   * Constructor for a new GeoPoint
   */
  public readonly GeoPoint = GeoPoint;

  /**
   * Determine whether the entity manager is open.
   * true until the entity manager has been closed
   */
  get isOpen(): boolean {
    return !!this.connection;
  }

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

  /**
   * Whether caching is disabled
   * @deprecated
   */
  @deprecated('EntityManager.isCachingEnabled()')
  get isCachingDisabled() {
    return !this.isCachingEnabled();
  }

  /**
   * Whether caching is enabled
   */
  isCachingEnabled(): this is { bloomFilter: BloomFilter, cacheWhiteList: Set<string>, cacheBlackList: Set<string> } {
    return !!this.bloomFilter;
  }

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

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

  /**
   * 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>
   */
  public readonly log: Logger = Logger.create(this);

  /**
   * The connector used for requests
   */
  public connection : Connector | null = null;

  /**
   * All managed and cached entity instances
   * @type Map<String,Entity>
   * @private
   */
  private entities: { [id: string]: Entity } = {};

  public readonly entityManagerFactory: EntityManagerFactory;

  public readonly metamodel: Metamodel;

  public readonly code: Code;

  public readonly modules: Modules = new Modules(this);

  /**
   * The current logged in user object
   */
  public me: model.User | null = null;

  /**
   * The current registered device object
   */
  public deviceMe: model.Device | null = null;

  /**
   * Returns the tokenStorage which will be used to authorize all requests.
   */
  public tokenStorage: TokenStorage = null as any; // is never null after em is ready

  /**
   * The bloom filter which contains staleness information of cached objects
   */
  public bloomFilter: BloomFilter | null = null;

  /**
   * Set of object ids that were revalidated after the Bloom filter was loaded.
   */
  public cacheWhiteList: Set<string> | null = 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.
   */
  public cacheBlackList: Set<string> | null = null;

  /**
   * Bloom filter refresh interval in seconds.
   */
  public bloomFilterRefresh: number = 60;

  /**
   * Bloom filter refresh Promise
   */
  public readonly bloomFilterLock = new Lockable();

  /**
   * A File factory for file objects.
   * The file factory can be called to create new instances for files.
   * The created instances implements the {@link File} interface
   */
  public File: FileFactory = null as any; // is never null after the em is ready

  /**
   * the shared connection data if this EntityManager shares the credentials with the EntityManagerFactory
   * @private
   */
  private connectData : ConnectData | null = null;

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

  /**
   * Connects this entityManager, used for synchronous and asynchronous initialization
   * @param useSharedTokenStorage Indicates if the shared credentials should be used
   */
  connected(useSharedTokenStorage?: boolean) {
    this.connection = this.entityManagerFactory.connection;
    this.bloomFilterRefresh = this.entityManagerFactory.staleness!;
    this.tokenStorage = useSharedTokenStorage
      ? this.entityManagerFactory.tokenStorage! : new TokenStorage(this.connection!.origin);
    this.connectData = useSharedTokenStorage ? this.entityManagerFactory.connectData : null;

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

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

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

    const bloomFilter = this.entityManagerFactory.connectData?.bloomFilter;
    if (this.bloomFilterRefresh > 0 && bloomFilter && typeof atob !== 'undefined' && !isNode) {
      this._updateBloomFilter(bloomFilter);
    }
  }

  /**
   * @param types
   * @return    * @private
   */
  private _createObjectFactory(types: { [type: string]: ManagedType<any> }): void {
    const values = Object.values(types);
    for (let i = 0; i < values.length; i += 1) {
      const type = values[i];
      const { name } = type;

      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,
        });
      }
    }
  }

  send(message: Message, ignoreCredentialError = true) {
    if (!this.connection) {
      throw new Error('This EntityManager is not connected.');
    }

    const msg = message;
    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 entityClass
   * @param key
   * @return
   */
  getReference<T extends Entity>(entityClass: Class<T> | string, key?: string): T {
    let id: string | null = null;
    let type: EntityType<any> | null;
    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.substring(0, keyIndex) : entityClass);
    } else {
      type = this.metamodel.entity(entityClass);
    }

    let entity = this.entities[id as string] as T;
    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 Builder<T>} for query creation and execution
   *
   * The query results are instances of the resultClass argument.
   *
   * @param resultClass - the type of the query result
   * @return A query builder to create one ore more queries for the specified class
   */
  createQueryBuilder<T extends Entity>(resultClass: Class<T>): Builder<T> {
    return new 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
   */
  clear(): void {
    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
   */
  close(): void {
    this.connection = null;

    return this.clear();
  }

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

  /**
   * Check if an object with the id from the given entity is already attached
   *
   * @param entity - entity instance
   * @return boolean indicating if entity with same id is attached
   */
  containsById(entity: Entity): boolean {
    return !!(entity && entity.id && 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 entity The entity instance to detach.
   * @return
   */
  detach(entity: Entity): Promise<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 entity - entity instance
   * @param [options] The load options
   * @return
   */
  resolveDepth<T extends Entity>(entity: T, options?: { refresh?: boolean, local?: boolean, depth?: number | boolean,
    resolved?: Entity[] }): Promise<T> {
    if (!options || !options.depth) {
      return Promise.resolve(entity);
    }

    const resolved = options.resolved || [];
    const promises: Promise<Entity | null>[] = [];
    const subOptions = {
      ...options,
      resolved,
      depth: options.depth === true ? true : options.depth - 1,
    };

    this.getSubEntities(entity, 1).forEach((subEntity: Entity) => {
      if (subEntity !== null && resolved!.indexOf(subEntity) === -1 && subEntity.id) {
        resolved!.push(subEntity);
        promises.push(this.load(subEntity.id, undefined, 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 entityClass - entity class
   * @param oid - Object ID
   * @param [options] The load options.
   * @return the loaded entity or null
   */
  load<T extends Entity>(
    entityClass: Class<T> | string,
    oid?: string,
    options?: { refresh?: boolean, local?: boolean, resolved?: Entity[] },
  ) : Promise<T | null> {
    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 && 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.type.fromJsonValue(state, response.entity, 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 entity
   * @param options
   * @return
   */
  insert(entity: Entity, options?: { depth?: number | boolean, refresh?: boolean }): Promise<Entity> {
    const opt = options || {};
    let isNew: boolean;

    return this._save(entity, opt, (state, json) => {
      if (state.version) {
        throw new 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 entity
   * @param options
   * @return
   */
  update(entity: Entity, options?: { force?: boolean, depth?: number | boolean, refresh?: boolean }): Promise<Entity> {
    const opt = options || {};

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

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

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

  /**
   * @param entity
   * @param options The save options
   * @param withoutLock Set true to save the entity without locking
   * @return
   */
  save<E extends Entity>(entity: E, options?: { force?: boolean, depth?: number | boolean, refresh?: boolean },
    withoutLock = false): Promise<E> {
    const opt = options || {};

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

        const { version, ...jsonWithoutVersion } = json;
        return new messages.ReplaceObject(state.bucket, state.key!, jsonWithoutVersion);
      }

      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 entity
   * @param cb pre-safe callback
   * @return
   */
  optimisticSave<E extends Entity>(entity: E, cb: (entity: E, abort: () => void) => any): Promise<E> {
    return Metadata.get(entity).withLock(() => this._optimisticSave(entity, cb));
  }

  /**
   * @param entity
   * @param cb pre-safe callback
   * @return
   * @private
   */
  private _optimisticSave<E extends Entity>(entity: E, cb: (entity: E, abort: () => void) => any): Promise<E> {
    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 entity
   * @param options
   * @param msgFactory
   * @return
   * @private
   */
  private _locklessSave<T extends Entity>(entity: T, options: { depth?: number | boolean, refresh?: boolean },
    msgFactory: MessageFactory): Promise<T> {
    this.attach(entity);
    const state = Metadata.get(entity);
    let refPromises;

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

    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.type.fromJsonValue(state, response.entity, 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 = { ...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 entity
   * @param options
   * @param msgFactory
   * @return
   * @private
   */
  private _save<T extends Entity>(entity: T, options: { depth?: number | boolean, refresh?: boolean },
    msgFactory: MessageFactory): Promise<T> {
    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 entity
   * @param depth
   * @param [resolved]
   * @param initialEntity
   * @return
   */
  getSubEntities(entity: Entity, depth?: boolean | number, resolved: Entity[] = [], initialEntity? : Entity): Entity[] {
    if (!depth) {
      return resolved;
    }

    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;
      const subEntities = this.getSubEntitiesByPath(entity, value.path);
      for (let i = 0, len = subEntities.length; i < len; i += 1) {
        const subEntity = subEntities[i];
        if (resolved.indexOf(subEntity) === -1 && subEntity !== obj) {
          resolved.push(subEntity);
          this.getSubEntities(subEntity, depth === true ? depth : depth - 1, resolved, obj);
        }
      }
    }

    return resolved;
  }

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

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

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

    return subEntities;
  }

  /**
   * Delete the entity instance.
   * @param entity
   * @param options The delete options
   * @return
   */
  delete<T extends Entity>(entity: T, options?: { force?: boolean, depth?: number | boolean }): Promise<T> {
    const opt = options || {};

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

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

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

      this.addToBlackList(entity.id);

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

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

      const subOptions = { ...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
   */
  flush(): Promise<any> {
    throw new Error('Not implemented.');
  }

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

  /**
   * Refresh the state of the instance from the database, overwriting changes made to the entity, if any.
   * @param entity - entity instance
   * @param options The refresh options
   * @return
   */
  refresh<T extends Entity>(entity: T, options: { depth?: number | boolean }): Promise<T | null> {
    if (!entity.id) {
      // entity is not persisted so far
      return Promise.resolve(entity);
    }

    return this.load(entity.id, undefined, { ...options, refresh: true });
  }

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

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

      this._attach(entity);
    }
  }

  private _attach(entity: Entity) {
    const metadata = Metadata.get(entity);
    if (metadata.isAttached) {
      if (metadata.db !== this) {
        throw new 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}/${uuid()}`;
      }
    }

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

  removeReference(entity: Entity) {
    const state = Metadata.get(entity);
    if (!state || !state.id) {
      throw new IllegalEntityError(entity);
    }

    delete this.entities[state.id];
  }

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

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

  login(username: string, password: string, loginOption: LoginOption | boolean) {
    if (this.me) {
      throw new 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)));
  }

  /**
   * Starts the MFA initiate process - note you must be logged in, to start the mfa setup process
   *
   * @returns A promise that resolves to an object with the following properties:
   * - qrCode: A Base64 representation of the QR code for MFA setup.
   * - keyUri: The URI for the MFA secret key.
   * @example
   * const { qrCode, keyUri } = await db.initMFA();
   * const code = await setupMFADevice(qrCode, keyUri);
   * const user = await db.finishMFA(code);
   */
  async initMFA(): Promise<MFAResponse> {
    return this.send(new messages.MFAInitChallenge()).then((resp) => {
      return {
        qrCode: resp.entity.qrCode as Base64<'png'>,
        keyUri: resp.entity.keyUri as string
      };
    });
  }

  /**
   * Finishes the MFA (Multi-Factor Authentication) initiation process.
   *
   * @param code - The verification code for MFA.
   * @returns A promise that resolves with the user object of the logged-in user.
   */
  public finishMFA(code: number): Promise<model.User> {
    return this.send(new messages.MFAInitFinish({ code })).then((resp) => {
      return this.User.me!; // to be here user is already logged in;
    });
  }

  /**
   * Submit a verification code after a login
   *
   * @param code - A 6 digit verification code
   * @param token - An MFA token obtained during the login process
   * @return The logged-in user object
   */
  async submitMFACode(code: number, token: string): Promise<model.User > {
    const loginType = this.tokenStorage.temporary ? LoginOption.SESSION_LOGIN : LoginOption.PERSIST_LOGIN;
    const msg = new messages.MFAToken({
      authToken: token,
      code,
      global: loginType === LoginOption.PERSIST_LOGIN,
    });
    return this.withLock(() => this._userRequest(msg, loginType)) as Promise< model.User>;
  }

  /**
   * Disables Multi-Factor Authentication for the currently logged in user.
   *
   * @throws {PersistentError} - Thrown when the user is not logged in.
   * @return A promise that resolves when Multi-Factor Authentication is successfully disabled.
   */
  disableMFA(): Promise<any> {
    if (!this.User.me)
      throw new PersistentError('User not Logged in');

    return this.send(new messages.MFADelete());
  }

  /**
   * Returns the current MFA status of the user
   *
   * @returns A promise that resolves to the MFA status of the user.
   * Possible values are 'ENABLED' if MFA is enabled, 'DISABLED' if MFA is
   * disabled, or 'PENDING' if MFA status is pending.
   */
  getMFAStatus(): Promise<'ENABLED' | 'DISABLED' | 'PENDING'> {
    return this.send(new messages.MFAStatus()).then((resp) => resp.entity);
  }

  loginWithOAuth(provider: string, options: OAuthOptions): any | string | Promise<model.User | null> {
    if (!this.connection) {
      throw new Error('This EntityManager is not connected.');
    }

    if (this.me) {
      throw new PersistentError('User is already logged in.');
    }

    const opt = {
      title: `Login with ${provider}`,
      timeout: 5 * 60 * 1000,
      state: {},
      loginOption: true,
      oAuthVersion: 2,
      open: openWindow,
      ...options,
    };

    if (opt.deviceCode) {
      return this._loginOAuthDevice(provider.toLowerCase(), opt);
    }

    if (opt.oAuthVersion !== 1 && !opt.path && !opt.deviceCode) {
      throw new Error('No OAuth path is provided to start the OAuth flow.');
    }

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

    const oAuthEndpoint = `${this.connection.origin}${this.connection.basePath}/db/User/OAuth/${provider.toLowerCase()}`;

    const url = opt.oAuthVersion === 1 ? oAuthEndpoint : appendQueryParams(opt.path!, {
      client_id: opt.clientId,
      scope: opt.scope,
      state: JSON.stringify(opt.state),
      redirect_uri: oAuthEndpoint,
    });

    const windowOptions = {
      title: opt.title,
      width: opt.width,
      height: opt.height,
    };

    if (opt.redirect) {
      // use oauth via redirect by opening the login in the same window
      return opt.open(url, { target: '_self', ...windowOptions }) || url;
    }

    const req = this._userRequest(new OAuthMessage(), opt.loginOption);
    if (!opt.open(url, windowOptions)) {
      throw new Error('The OAuth flow with a Pop-Up can only be issued in browsers. Add a redirect URL to the options to return to your app via that redirect after the OAuth flow succeed.');
    }

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

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

  private _loginOAuthDevice(provider: string, opt: OAuthOptions): Promise<model.User | null> {
    return this._userRequest(new messages.OAuth2(provider, opt.deviceCode), opt.loginOption)
      .catch(() => new Promise((resolve) => setTimeout(resolve, 5000))
        .then(() => this._loginOAuthDevice(provider, opt))) as Promise<model.User | null>;
  }

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

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

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

  newPasswordWithToken(token: string, newPassword: string, loginOption?: LoginOption | boolean) {
    return this.withLock(() => (
      this._userRequest(new messages.NewPassword({ token, newPassword }), loginOption)
    ));
  }

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

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

  private _updateUser(obj: JsonMap, updateMe = false) {
    const user = this.getReference(obj.id as string) as model.User;
    const metadata = Metadata.get(user);
    metadata.type.fromJsonValue(metadata, obj, user, { persisting: true });

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

    return user;
  }

  private _logout() {
    this.me = null;
    this.token = null;
    if (this.connectData) {
      delete this.connectData.user;
    }
  }

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

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

          if (e.status === StatusCode.FORBIDDEN) {
            const { data } = e;
            throw new MFAError(data['baqend-mfa-auth-token']); // If MFA is required: throw an error containing the auth token
          }

          throw e;
        },
      );
  }

  /**
   * @param deviceType The OS of the device (IOS/Android)
   * @param subscription WebPush subscription
   * @param device
   * @return
   */
  registerDevice(deviceType: string, subscription: PushSubscription | { token: string }, device: model.Device | null):
  Promise<model.Device> {
    const msg = new messages.DeviceRegister({ devicetype: deviceType, subscription: subscription as JsonMap, device });

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

  private _updateDevice(obj: JsonMap) {
    const device = this.getReference(obj.id as string);
    const metadata = Metadata.get(device);
    metadata.type.fromJsonValue(metadata, obj, device, { persisting: true });

    this.deviceMe = device;
    if (this.connectData) {
      this.connectData.device = obj;
    }
    return device;
  }

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

        throw e;
      });
  }

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

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

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

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

    return result;
  }

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

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

  refreshBloomFilter(): Promise<BloomFilter | null> {
    if (!this.isCachingEnabled()) {
      return Promise.resolve(null);
    }

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

  private _updateBloomFilter(bloomFilter: JsonMap) {
    this.bloomFilter = new BloomFilter(bloomFilter as { m: number, h: number, b: string });
    this.cacheWhiteList = new Set();
    this.cacheBlackList = new Set();
  }

  /**
   * Checks the freshness of the bloom filter and does a reload if necessary
   */
  ensureBloomFilterFreshness(): void {
    if (!this.isCachingEnabled()) {
      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 id The object id to check
   * @return Indicates if the resource must be revalidated
   */
  mustRevalidate(id: string): boolean {
    if (isNode) {
      return false;
    }

    this.ensureBloomFilterFreshness();

    if (!this.isCachingEnabled() || !this.bloomFilterLock.isReady) {
      return true;
    }

    if (CACHE_REPLACEMENT_SUPPORTED && this.cacheWhiteList.has(id)) {
      return false;
    }

    return this.cacheBlackList.has(id) || this.bloomFilter.contains(id);
  }

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

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

  /**
   * Creates a absolute url for the given relative one
   * @param relativePath the relative url
   * @param authorize indicates if authorization credentials should be generated and be attached to the url
   * @return a absolute url which is optionally signed with a resource token which authenticates the currently
   * logged in user
   */
  createURL(relativePath: string, authorize?: boolean): Promise<string> {
    const { connection } = this;
    if (!connection) {
      throw new Error('This EntityManager is not connected.');
    }

    return this.tokenStorage.signPath(connection.basePath + relativePath, authorize)
      .then((path) => {
        let url = connection.origin + path;
        if (this.mustRevalidate(relativePath)) {
          url += `${authorize ? '&' : '?'}BCB`;
        }

        return url;
      });
  }

  /**
   * Requests a perpetual token for the given user
   *
   * Only users with the admin role are allowed to request an API token.
   *
   * @param entityClass
   * @param user The user object or id of the user object
   * @return
   */
  requestAPIToken(entityClass: Class<model.User>, user: model.User | string): Promise<JsonMap> {
    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 entityClass
   * @param user The user object or id of the user object
   */
  revokeAllTokens(entityClass: Class<model.User>, user: model.User | string): Promise<any> {
    const userObj = this._getUserReference(entityClass, user);

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

  executeQuery<T extends Entity>(resultClass: Class<T>, query: string, triggeredBy: string): Promise<any[]> {
    const type = resultClass ? this.metamodel.entity(resultClass) : null;
    const backendType = type?.getMetadata("backendType")

    if (!type || !backendType || backendType !== "analytics") {
      throw new Error("Only analytics entities can execute queries")
    }

    const uriSize = (this.connection?.host.length || 0) + query.length;
    let msg;
    if (uriSize > Query.MAX_URI_SIZE) {
      msg = new messages.AdhocQueryPOST(type.name, undefined, undefined, undefined, triggeredBy, query)
        .entity(query, 'text');
    } else {
      msg = new message.AdhocQuery(type.name, query, undefined, undefined, undefined, undefined, undefined, triggeredBy)
    }

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

  private _getUserReference(entityClass: Class<model.User> | string, user: string | model.User): model.User {
    if (typeof user === 'string') {
      return this.getReference(entityClass, user);
    }

    return user;
  }
}

export interface EntityManager extends Lockable {
  [Class: string]: EntityFactory<any> | ManagedFactory<any> | any;

  /**
   * 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
   */
  readonly User: UserFactory;

  /**
   * 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
   */
  readonly Role: 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.
   */
  readonly Device: 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 Entity} or the {@link Managed} interface
   * whenever the class is an entity or embedded object
   * @name [YourEntityClass: string]
   * @memberOf EntityManager.prototype
   * @type {*}
   */
}
