/**
 * @flow
 * Database Reference representation wrapper
 */
import Query from './Query';
import DataSnapshot from './DataSnapshot';
import OnDisconnect from './OnDisconnect';
import { getLogger } from '../../utils/log';
import { getNativeModule } from '../../utils/native';
import ReferenceBase from '../../utils/ReferenceBase';

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

import SyncTree from '../../utils/SyncTree';

import type Database from './';
import type { DatabaseModifier, FirebaseError } from '../../types';

// 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',
};

type DatabaseListener = {
  listenerId: number,
  eventName: string,
  successCallback: Function,
  failureCallback?: Function,
};

/**
 * @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 {
  _database: Database;

  _query: Query;

  _refListeners: { [listenerId: number]: DatabaseListener };

  then: (a?: any) => Promise<any>;

  catch: (a?: any) => Promise<any>;

  constructor(
    database: Database,
    path: string,
    existingModifiers?: Array<DatabaseModifier>
  ) {
    super(path);
    this._refListeners = {};
    this._database = database;
    this._query = new Query(this, existingModifiers);
    getLogger(database).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): Promise<void> {
    return getNativeModule(this._database).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<void> {
    return promiseOrCallback(
      getNativeModule(this._database).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<void> {
    const _priority = this._serializeAnyType(priority);

    return promiseOrCallback(
      getNativeModule(this._database).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<void> {
    const _value = this._serializeAnyType(value);
    const _priority = this._serializeAnyType(priority);

    return promiseOrCallback(
      getNativeModule(this._database).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<void> {
    const value = this._serializeObject(val);

    return promiseOrCallback(
      getNativeModule(this._database).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<void> {
    return promiseOrCallback(
      getNativeModule(this._database).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: ?DataSnapshot
    ) => *,
    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) {
            onComplete(error, committed, null);
          } else {
            onComplete(null, committed, new DataSnapshot(this, snapshotData));
          }
        }

        if (error) return reject(error);
        return resolve({
          committed,
          snapshot: new DataSnapshot(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: DataSnapshot) => void,
    cancelOrContext: (error: FirebaseError) => void,
    context?: Object
  ) {
    return getNativeModule(this._database)
      .once(this._getRefKey(), this.path, this._query.getModifiers(), eventName)
      .then(({ snapshot }) => {
        const _snapshot = new DataSnapshot(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);
        throw error;
      });
  }

  /**
   *
   * @param value
   * @param onComplete
   * @returns {*}
   */
  push(value: any, onComplete?: Function): Reference | Promise<void> {
    const name = generatePushID(this._database._serverTimeOffset);

    const pushRef = this.child(name);
    const thennablePushRef = this.child(name);

    let promise;
    if (value != null) {
      promise = thennablePushRef.set(value, onComplete).then(() => pushRef);
    } else {
      promise = Promise.resolve(pushRef);
    }

    thennablePushRef.then = promise.then.bind(promise);
    thennablePushRef.catch = promise.catch.bind(promise);

    if (isFunction(onComplete)) {
      promise.catch(() => {});
    }

    return thennablePushRef;
  }

  /**
   * 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 {OnDisconnect}
   */
  onDisconnect(): OnDisconnect {
    return new OnDisconnect(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._database.databaseUrl}${this.path}`;
  }

  /**
   * Return a JSON-serializable representation of this object.
   * @returns {string}
   */
  toJSON(): string {
    return this.toString();
  }

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

  /**
   * INTERNALS
   */

  /**
   * Generate a unique registration key.
   *
   * @return {string}
   */
  _getRegistrationKey(eventType: string): string {
    return `$${this._database.databaseUrl}$/${
      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.databaseUrl}$/${
      this.path
    }$${this._query.queryIdentifier()}`;
  }

  /**
   *
   * @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: DataSnapshot => any,
    cancelCallbackOrContext?: Object => any | Object,
    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.app.name,
      dbURL: this._database.databaseUrl,
      eventRegistrationKey,
    };

    SyncTree.addRegistration({
      ...registrationObj,
      listener: _context ? callback.bind(_context) : callback,
    });

    if (cancelCallbackOrContext && 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
      SyncTree.addRegistration({
        ref: this,
        once: true,
        path: this.path,
        key: this._getRefKey(),
        appName: this._database.app.name,
        dbURL: this._database.databaseUrl,
        eventType: `${eventType}$cancelled`,
        eventRegistrationKey: registrationCancellationKey,
        listener: _context
          ? cancelCallbackOrContext.bind(_context)
          : cancelCallbackOrContext,
      });
    }

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

    // increment number of listeners - just a 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 SyncTree.removeListenersForRegistrations(
        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 registration = SyncTree.getOneByPathEventListener(
        this.path,
        eventType,
        originalCallback
      );
      if (!registration) return [];

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

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

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

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

    return SyncTree.removeListenersForRegistrations(registrations);
  }
}