/**
 * @flow
 */

import Query from './query.js';
import Snapshot from './snapshot';
import Disconnect from './disconnect';
import ReferenceBase from './../../utils/ReferenceBase';

import {
  promiseOrCallback,
  isFunction,
  isObject,
  isString,
  tryJSONParse,
  tryJSONStringify,
  generatePushID,
} from './../../utils';

import INTERNALS from './../../internals';

// track all event registrations by path
let listeners = 0;

/**
 * Enum for event types
 * @readonly
 * @enum {String}
 */
const ReferenceEventTypes = {
  value: 'value',
  child_added: 'child_added',
  child_removed: 'child_removed',
  child_changed: 'child_changed',
  child_moved: 'child_moved',
};

/**
 * @typedef {String} ReferenceLocation - Path to location in the database, relative
 * to the root reference. Consists of a path where segments are separated by a
 * forward slash (/) and ends in a ReferenceKey - except the root location, which
 * has no ReferenceKey.
 *
 * @example
 * // root reference location: '/'
 * // non-root reference: '/path/to/referenceKey'
 */

/**
 * @typedef {String} ReferenceKey - Identifier for each location that is unique to that
 * location, within the scope of its parent. The last part of a ReferenceLocation.
 */

/**
 * Represents a specific location in your Database that can be used for
 * reading or writing data.
 *
 * You can reference the root using firebase.database().ref() or a child location
 * by calling firebase.database().ref("child/path").
 *
 * @link https://firebase.google.com/docs/reference/js/firebase.database.Reference
 * @class Reference
 * @extends ReferenceBase
 */
export default class Reference extends ReferenceBase {

  _refListeners: { [listenerId: number]: DatabaseListener };
  _database: Object;
  _query: Query;

  constructor(database: Object, path: string, existingModifiers?: Array<DatabaseModifier>) {
    super(path, database);
    this._promise = null;
    this._refListeners = {};
    this._database = database;
    this._query = new Query(this, path, existingModifiers);
    this.log = this._database.log;
    this.log.debug('Created new Reference', this._getRefKey());
  }

  /**
   * By calling `keepSynced(true)` on a location, the data for that location will
   * automatically be downloaded and kept in sync, even when no listeners are
   * attached for that location. Additionally, while a location is kept synced,
   *  it will not be evicted from the persistent disk cache.
   *
   * @link https://firebase.google.com/docs/reference/android/com/google/firebase/database/Query.html#keepSynced(boolean)
   * @param bool
   * @returns {*}
   */
  keepSynced(bool: boolean) {
    return this._database._native.keepSynced(this._getRefKey(), this.path, this._query.getModifiers(), bool);
  }

  /**
   * Writes data to this Database location.
   *
   * @link https://firebase.google.com/docs/reference/js/firebase.database.Reference#set
   * @param value
   * @param onComplete
   * @returns {Promise}
   */
  set(value: any, onComplete?: Function): Promise {
    return promiseOrCallback(
      this._database._native.set(this.path, this._serializeAnyType(value)),
      onComplete,
    );
  }

  /**
   * Sets a priority for the data at this Database location.
   *
   * @link https://firebase.google.com/docs/reference/js/firebase.database.Reference#setPriority
   * @param priority
   * @param onComplete
   * @returns {Promise}
   */
  setPriority(priority: string | number | null, onComplete?: Function): Promise {
    const _priority = this._serializeAnyType(priority);

    return promiseOrCallback(
      this._database._native.setPriority(this.path, _priority),
      onComplete,
    );
  }

  /**
   * Writes data the Database location. Like set() but also specifies the priority for that data.
   *
   * @link https://firebase.google.com/docs/reference/js/firebase.database.Reference#setWithPriority
   * @param value
   * @param priority
   * @param onComplete
   * @returns {Promise}
   */
  setWithPriority(value: any, priority: string | number | null, onComplete?: Function): Promise {
    const _value = this._serializeAnyType(value);
    const _priority = this._serializeAnyType(priority);

    return promiseOrCallback(
      this._database._native.setWithPriority(this.path, _value, _priority),
      onComplete,
    );
  }

  /**
   * Writes multiple values to the Database at once.
   *
   * @link https://firebase.google.com/docs/reference/js/firebase.database.Reference#update
   * @param val
   * @param onComplete
   * @returns {Promise}
   */
  update(val: Object, onComplete?: Function): Promise {
    const value = this._serializeObject(val);

    return promiseOrCallback(
      this._database._native.update(this.path, value),
      onComplete,
    );
  }

  /**
   * Removes the data at this Database location.
   *
   * @link https://firebase.google.com/docs/reference/js/firebase.database.Reference#remove
   * @param onComplete
   * @return {Promise}
   */
  remove(onComplete?: Function): Promise {
    return promiseOrCallback(
      this._database._native.remove(this.path),
      onComplete,
    );
  }

  /**
   * Atomically modifies the data at this location.
   *
   * @link https://firebase.google.com/docs/reference/js/firebase.database.Reference#transaction
   * @param transactionUpdate
   * @param onComplete
   * @param applyLocally
   */
  transaction(
    transactionUpdate: Function,
    onComplete: (error: ?Error, committed: boolean, snapshot: ?Snapshot) => *,
    applyLocally: boolean = false,
  ) {
    if (!isFunction(transactionUpdate)) {
      return Promise.reject(
        new Error('Missing transactionUpdate function argument.'),
      );
    }

    return new Promise((resolve, reject) => {
      const onCompleteWrapper = (error, committed, snapshotData) => {
        if (isFunction(onComplete)) {
          if (error) return onComplete(error, committed, null);
          return onComplete(null, committed, new Snapshot(this, snapshotData));
        }

        if (error) return reject(error);
        return resolve({ committed, snapshot: new Snapshot(this, snapshotData) });
      };

      // start the transaction natively
      this._database._transactionHandler.add(this, transactionUpdate, onCompleteWrapper, applyLocally);
    });
  }


  /**
   *
   * @param eventName
   * @param successCallback
   * @param cancelOrContext
   * @param context
   * @returns {Promise.<any>}
   */
  once(
    eventName: string = 'value',
    successCallback: (snapshot: Object) => void,
    cancelOrContext: (error: FirebaseError) => void,
    context?: Object,
  ) {
    return this._database._native.once(this._getRefKey(), this.path, this._query.getModifiers(), eventName)
      .then(({ snapshot }) => {
        const _snapshot = new Snapshot(this, snapshot);

        if (isFunction(successCallback)) {
          if (isObject(cancelOrContext)) successCallback.bind(cancelOrContext)(_snapshot);
          if (context && isObject(context)) successCallback.bind(context)(_snapshot);
          successCallback(_snapshot);
        }

        return _snapshot;
      })
      .catch((error) => {
        if (isFunction(cancelOrContext)) return cancelOrContext(error);
        return error;
      });
  }

  /**
   *
   * @param value
   * @param onComplete
   * @returns {*}
   */
  push(value: any, onComplete?: Function): Reference | Promise {
    if (value === null || value === undefined) {
      return new Reference(this._database, `${this.path}/${generatePushID(this._database._serverTimeOffset)}`);
    }

    const newRef = new Reference(this._database, `${this.path}/${generatePushID(this._database._serverTimeOffset)}`);
    const promise = newRef.set(value);

    // if callback provided then internally call the set promise with value
    if (isFunction(onComplete)) {
      return promise
        .then(() => onComplete(null, newRef))
        .catch(error => onComplete(error, null));
    }

    // otherwise attach promise to 'thenable' reference and return the
    // new reference
    newRef._setThenable(promise);
    return newRef;
  }

  /**
   * MODIFIERS
   */

  /**
   *
   * @returns {Reference}
   */
  orderByKey(): Reference {
    return this.orderBy('orderByKey');
  }

  /**
   *
   * @returns {Reference}
   */
  orderByPriority(): Reference {
    return this.orderBy('orderByPriority');
  }

  /**
   *
   * @returns {Reference}
   */
  orderByValue(): Reference {
    return this.orderBy('orderByValue');
  }

  /**
   *
   * @param key
   * @returns {Reference}
   */
  orderByChild(key: string): Reference {
    return this.orderBy('orderByChild', key);
  }

  /**
   *
   * @param name
   * @param key
   * @returns {Reference}
   */
  orderBy(name: string, key?: string): Reference {
    const newRef = new Reference(this._database, this.path, this._query.getModifiers());
    newRef._query.orderBy(name, key);
    return newRef;
  }

  /**
   * LIMITS
   */

  /**
   *
   * @param limit
   * @returns {Reference}
   */
  limitToLast(limit: number): Reference {
    return this.limit('limitToLast', limit);
  }

  /**
   *
   * @param limit
   * @returns {Reference}
   */
  limitToFirst(limit: number): Reference {
    return this.limit('limitToFirst', limit);
  }

  /**
   *
   * @param name
   * @param limit
   * @returns {Reference}
   */
  limit(name: string, limit: number): Reference {
    const newRef = new Reference(this._database, this.path, this._query.getModifiers());
    newRef._query.limit(name, limit);
    return newRef;
  }

  /**
   * FILTERS
   */

  /**
   *
   * @param value
   * @param key
   * @returns {Reference}
   */
  equalTo(value: any, key?: string): Reference {
    return this.filter('equalTo', value, key);
  }

  /**
   *
   * @param value
   * @param key
   * @returns {Reference}
   */
  endAt(value: any, key?: string): Reference {
    return this.filter('endAt', value, key);
  }

  /**
   *
   * @param value
   * @param key
   * @returns {Reference}
   */
  startAt(value: any, key?: string): Reference {
    return this.filter('startAt', value, key);
  }

  /**
   *
   * @param name
   * @param value
   * @param key
   * @returns {Reference}
   */
  filter(name: string, value: any, key?: string): Reference {
    const newRef = new Reference(this._database, this.path, this._query.getModifiers());
    newRef._query.filter(name, value, key);
    return newRef;
  }

  /**
   *
   * @returns {Disconnect}
   */
  onDisconnect(): Disconnect {
    return new Disconnect(this);
  }

  /**
   * Creates a Reference to a child of the current Reference, using a relative path.
   * No validation is performed on the path to ensure it has a valid format.
   * @param {String} path relative to current ref's location
   * @returns {!Reference} A new Reference to the path provided, relative to the current
   * Reference
   * {@link https://firebase.google.com/docs/reference/js/firebase.database.Reference#child}
   */
  child(path: string): Reference {
    return new Reference(this._database, `${this.path}/${path}`);
  }

  /**
   * Return the ref as a path string
   * @returns {string}
   */
  toString(): string {
    return this.path;
  }

  /**
   * Returns whether another Reference represent the same location and are from the
   * same instance of firebase.app.App - multiple firebase apps not currently supported.
   * @param {Reference} otherRef - Other reference to compare to this one
   * @return {Boolean} Whether otherReference is equal to this one
   *
   * {@link https://firebase.google.com/docs/reference/js/firebase.database.Reference#isEqual}
   */
  isEqual(otherRef: Reference): boolean {
    return !!otherRef
      && otherRef.constructor === Reference
      && otherRef.key === this.key
      && this._query.queryIdentifier() === otherRef._query.queryIdentifier();
  }

  /**
   * GETTERS
   */

  /**
   * The parent location of a Reference, or null for the root Reference.
   * @type {Reference}
   *
   * {@link https://firebase.google.com/docs/reference/js/firebase.database.Reference#parent}
   */
  get parent(): Reference | null {
    if (this.path === '/') return null;
    return new Reference(this._database, this.path.substring(0, this.path.lastIndexOf('/')));
  }

  /**
   * A reference to itself
   * @type {!Reference}
   *
   * {@link https://firebase.google.com/docs/reference/js/firebase.database.Reference#ref}
   */
  get ref(): Reference {
    return this;
  }

  /**
   * Reference to the root of the database: '/'
   * @type {!Reference}
   *
   * {@link https://firebase.google.com/docs/reference/js/firebase.database.Reference#root}
   */
  get root(): Reference {
    return new Reference(this._database, '/');
  }

  /**
   * Access then method of promise if set
   * @return {*}
   */
  then(fnResolve, fnReject) {
    if (isFunction(fnResolve) && this._promise && this._promise.then) {
      return this._promise.then.bind(this._promise)((result) => {
        this._promise = null;
        return fnResolve(result);
      }, (possibleErr) => {
        this._promise = null;

        if (isFunction(fnReject)) {
          return fnReject(possibleErr);
        }

        throw possibleErr;
      });
    }

    throw new Error("Cannot read property 'then' of undefined.");
  }

  /**
   * Access catch method of promise if set
   * @return {*}
   */
  catch(fnReject) {
    if (isFunction(fnReject) && this._promise && this._promise.catch) {
      return this._promise.catch.bind(this._promise)((possibleErr) => {
        this._promise = null;
        return fnReject(possibleErr);
      });
    }

    throw new Error("Cannot read property 'catch' of undefined.");
  }

  /**
   * INTERNALS
   */

  /**
   * Generate a unique registration key.
   *
   * @return {string}
   */
  _getRegistrationKey(eventType) {
    return `$${this._database._appName}$/${this.path}$${this._query.queryIdentifier()}$${listeners}$${eventType}`;
  }

  /**
   * Generate a string that uniquely identifies this
   * combination of path and query modifiers
   *
   * @return {string}
   * @private
   */
  _getRefKey() {
    return `$${this._database._appName}$/${this.path}$${this._query.queryIdentifier()}`;
  }

  /**
   * Return instance of db logger
   */
  get log() {
    return this._database.log;
  }

  /**
   * Set the promise this 'thenable' reference relates to
   * @param promise
   * @private
   */
  _setThenable(promise) {
    this._promise = promise;
  }


  /**
   *
   * @param obj
   * @returns {Object}
   * @private
   */
  _serializeObject(obj: Object) {
    if (!isObject(obj)) return obj;

    // json stringify then parse it calls toString on Objects / Classes
    // that support it i.e new Date() becomes a ISO string.
    return tryJSONParse(tryJSONStringify(obj));
  }

  /**
   *
   * @param value
   * @returns {*}
   * @private
   */
  _serializeAnyType(value: any) {
    if (isObject(value)) {
      return {
        type: 'object',
        value: this._serializeObject(value),
      };
    }

    return {
      type: typeof value,
      value,
    };
  }

  /**
   * Register a listener for data changes at the current ref's location.
   * The primary method of reading data from a Database.
   *
   * Listeners can be unbound using {@link off}.
   *
   * Event Types:
   *
   * - value: {@link callback}.
   * - child_added: {@link callback}
   * - child_removed: {@link callback}
   * - child_changed: {@link callback}
   * - child_moved: {@link callback}
   *
   * @param {ReferenceEventType} eventType - Type of event to attach a callback for.
   * @param {ReferenceEventCallback} callback - Function that will be called
   * when the event occurs with the new data.
   * @param {cancelCallbackOrContext=} cancelCallbackOrContext - Optional callback that is called
   * if the event subscription fails. {@link cancelCallbackOrContext}
   * @param {*=} context - Optional object to bind the callbacks to when calling them.
   * @returns {ReferenceEventCallback} callback function, unmodified (unbound), for
   * convenience if you want to pass an inline function to on() and store it later for
   * removing using off().
   *
   * {@link https://firebase.google.com/docs/reference/js/firebase.database.Reference#on}
   */
  on(eventType: string, callback: () => any, cancelCallbackOrContext?: () => any, context?: Object): Function {
    if (!eventType) {
      throw new Error('Query.on failed: Function called with 0 arguments. Expects at least 2.');
    }

    if (!isString(eventType) || !ReferenceEventTypes[eventType]) {
      throw new Error(`Query.on failed: First argument must be a valid string event type: "${Object.keys(ReferenceEventTypes).join(', ')}"`);
    }

    if (!callback) {
      throw new Error('Query.on failed: Function called with 1 argument. Expects at least 2.');
    }

    if (!isFunction(callback)) {
      throw new Error('Query.on failed: Second argument must be a valid function.');
    }

    if (cancelCallbackOrContext && !isFunction(cancelCallbackOrContext) && !isObject(context) && !isObject(cancelCallbackOrContext)) {
      throw new Error('Query.on failed: Function called with 3 arguments, but third optional argument `cancelCallbackOrContext` was not a function.');
    }

    if (cancelCallbackOrContext && !isFunction(cancelCallbackOrContext) && context) {
      throw new Error('Query.on failed: Function called with 4 arguments, but third optional argument `cancelCallbackOrContext` was not a function.');
    }

    const eventRegistrationKey = this._getRegistrationKey(eventType);
    const registrationCancellationKey = `${eventRegistrationKey}$cancelled`;
    const _context = (cancelCallbackOrContext && !isFunction(cancelCallbackOrContext)) ? cancelCallbackOrContext : context;
    const registrationObj = {
      eventType,
      ref: this,
      path: this.path,
      key: this._getRefKey(),
      appName: this._database._appName,
      eventRegistrationKey,
    };

    this._syncTree.addRegistration(registrationObj, _context ? callback.bind(_context) : callback);

    if (isFunction(cancelCallbackOrContext)) {
      // cancellations have their own separate registration
      // as these are one off events, and they're not guaranteed
      // to occur either, only happens on failure to register on native
      this._syncTree.addRegistration(
        {
          ref: this,
          once: true,
          path: this.path,
          key: this._getRefKey(),
          appName: this._database._appName,
          eventType: `${eventType}$cancelled`,
          eventRegistrationKey: registrationCancellationKey,
        },
        _context ? cancelCallbackOrContext.bind(_context) : cancelCallbackOrContext,
      );
    }

    // initialise the native listener if not already listening
    this._database._native.on({
      eventType,
      path: this.path,
      key: this._getRefKey(),
      appName: this._database._appName,
      modifiers: this._query.getModifiers(),
      hasCancellationCallback: isFunction(cancelCallbackOrContext),
      registration: {
        eventRegistrationKey,
        key: registrationObj.key,
        registrationCancellationKey,
      },
    });

    // increment number of listeners - just s short way of making
    // every registration unique per .on() call
    listeners += 1;

    // return original unbound successCallback for
    // the purposes of calling .off(eventType, callback) at a later date
    return callback;
  }

  /**
   * Detaches a callback previously attached with on().
   *
   * Detach a callback previously attached with on(). Note that if on() was called
   * multiple times with the same eventType and callback, the callback will be called
   * multiple times for each event, and off() must be called multiple times to
   * remove the callback. Calling off() on a parent listener will not automatically
   * remove listeners registered on child nodes, off() must also be called on any
   * child listeners to remove the callback.
   *
   *  If a callback is not specified, all callbacks for the specified eventType will be removed.
   * Similarly, if no eventType or callback is specified, all callbacks for the Reference will be removed.
   * @param eventType
   * @param originalCallback
   */
  off(eventType?: string = '', originalCallback?: () => any) {
    if (!arguments.length) {
      // Firebase Docs:
      //     if no eventType or callback is specified, all callbacks for the Reference will be removed.
      return this._syncTree.removeListenersForRegistrations(this._syncTree.getRegistrationsByPath(this.path));
    }

    /*
     * VALIDATE ARGS
     */
    if (eventType && (!isString(eventType) || !ReferenceEventTypes[eventType])) {
      throw new Error(`Query.off failed: First argument must be a valid string event type: "${Object.keys(ReferenceEventTypes).join(', ')}"`);
    }

    if (originalCallback && !isFunction(originalCallback)) {
      throw new Error('Query.off failed: Function called with 2 arguments, but second optional argument was not a function.');
    }

    // Firebase Docs:
    //     Note that if on() was called
    //     multiple times with the same eventType and callback, the callback will be called
    //     multiple times for each event, and off() must be called multiple times to
    //     remove the callback.
    // Remove only a single registration
    if (eventType && originalCallback) {
      const registrations = this._syncTree.getRegistrationsByPathEvent(this.path, eventType);

      // remove the paired cancellation registration if any exist
      this._syncTree.removeListenersForRegistrations([`${registrations[0]}$cancelled`]);

      // remove only the first registration to match firebase web sdk
      // call multiple times to remove multiple registrations
      return this._syncTree.removeListenerRegistrations(originalCallback, [registrations[0]]);
    }

    // Firebase Docs:
    //     If a callback is not specified, all callbacks for the specified eventType will be removed.
    const registrations = this._syncTree.getRegistrationsByPathEvent(this.path, eventType);

    this._syncTree.removeListenersForRegistrations(
      this._syncTree.getRegistrationsByPathEvent(this.path, `${eventType}$cancelled`),
    );

    return this._syncTree.removeListenersForRegistrations(registrations);
  }

  get _syncTree() {
    return INTERNALS.SyncTree;
  }
}
