/**
 * @flow
 * Query representation wrapper
 */
import DocumentSnapshot from './DocumentSnapshot';
import FieldPath from './FieldPath';
import QuerySnapshot from './QuerySnapshot';
import { buildNativeArray, buildTypeMap } from './utils/serialize';
import { getAppEventName, SharedEventEmitter } from '../../utils/events';
import { getLogger } from '../../utils/log';
import { firestoreAutoId, isFunction, isObject } from '../../utils';
import { getNativeModule } from '../../utils/native';

import type Firestore from './';
import type Path from './Path';
import type { MetadataChanges, QueryDirection, QueryOperator } from './types';

const DIRECTIONS: { [QueryDirection]: string } = {
  ASC: 'ASCENDING',
  asc: 'ASCENDING',
  DESC: 'DESCENDING',
  desc: 'DESCENDING',
};

const OPERATORS: { [QueryOperator]: string } = {
  '=': 'EQUAL',
  '==': 'EQUAL',
  '>': 'GREATER_THAN',
  '>=': 'GREATER_THAN_OR_EQUAL',
  '<': 'LESS_THAN',
  '<=': 'LESS_THAN_OR_EQUAL',
};

type NativeFieldPath = {|
  elements?: string[],
  string?: string,
  type: 'fieldpath' | 'string',
|};
type FieldFilter = {|
  fieldPath: NativeFieldPath,
  operator: string,
  value: any,
|};
type FieldOrder = {|
  direction: string,
  fieldPath: NativeFieldPath,
|};
type QueryOptions = {
  endAt?: any[],
  endBefore?: any[],
  limit?: number,
  offset?: number,
  selectFields?: string[],
  startAfter?: any[],
  startAt?: any[],
};

export type ObserverOnError = Object => void;
export type ObserverOnNext = QuerySnapshot => void;

export type Observer = {
  error?: ObserverOnError,
  next: ObserverOnNext,
};

const buildNativeFieldPath = (
  fieldPath: string | FieldPath
): NativeFieldPath => {
  if (fieldPath instanceof FieldPath) {
    return {
      elements: fieldPath._segments,
      type: 'fieldpath',
    };
  }
  return {
    string: fieldPath,
    type: 'string',
  };
};

/**
 * @class Query
 */
export default class Query {
  _fieldFilters: FieldFilter[];
  _fieldOrders: FieldOrder[];
  _firestore: Firestore;
  _iid: number;
  _queryOptions: QueryOptions;
  _referencePath: Path;

  constructor(
    firestore: Firestore,
    path: Path,
    fieldFilters?: FieldFilter[],
    fieldOrders?: FieldOrder[],
    queryOptions?: QueryOptions
  ) {
    this._fieldFilters = fieldFilters || [];
    this._fieldOrders = fieldOrders || [];
    this._firestore = firestore;
    this._queryOptions = queryOptions || {};
    this._referencePath = path;
  }

  get firestore(): Firestore {
    return this._firestore;
  }

  endAt(...snapshotOrVarArgs: any[]): Query {
    const options = {
      ...this._queryOptions,
      endAt: this._buildOrderByOption(snapshotOrVarArgs),
    };

    return new Query(
      this.firestore,
      this._referencePath,
      this._fieldFilters,
      this._fieldOrders,
      options
    );
  }

  endBefore(...snapshotOrVarArgs: any[]): Query {
    const options = {
      ...this._queryOptions,
      endBefore: this._buildOrderByOption(snapshotOrVarArgs),
    };

    return new Query(
      this.firestore,
      this._referencePath,
      this._fieldFilters,
      this._fieldOrders,
      options
    );
  }

  get(): Promise<QuerySnapshot> {
    return getNativeModule(this._firestore)
      .collectionGet(
        this._referencePath.relativeName,
        this._fieldFilters,
        this._fieldOrders,
        this._queryOptions
      )
      .then(nativeData => new QuerySnapshot(this._firestore, this, nativeData));
  }

  limit(limit: number): Query {
    // TODO: Validation
    // validate.isInteger('n', n);

    const options = {
      ...this._queryOptions,
      limit,
    };
    return new Query(
      this.firestore,
      this._referencePath,
      this._fieldFilters,
      this._fieldOrders,
      options
    );
  }

  onSnapshot(
    optionsOrObserverOrOnNext: MetadataChanges | Observer | ObserverOnNext,
    observerOrOnNextOrOnError?: Observer | ObserverOnNext | ObserverOnError,
    onError?: ObserverOnError
  ) {
    let observer: Observer;
    let metadataChanges = {};
    // Called with: onNext, ?onError
    if (isFunction(optionsOrObserverOrOnNext)) {
      if (observerOrOnNextOrOnError && !isFunction(observerOrOnNextOrOnError)) {
        throw new Error(
          'Query.onSnapshot failed: Second argument must be a valid function.'
        );
      }
      // $FlowExpectedError: Not coping with the overloaded method signature
      observer = {
        next: optionsOrObserverOrOnNext,
        error: observerOrOnNextOrOnError,
      };
    } else if (
      optionsOrObserverOrOnNext &&
      isObject(optionsOrObserverOrOnNext)
    ) {
      // Called with: Observer
      if (optionsOrObserverOrOnNext.next) {
        if (isFunction(optionsOrObserverOrOnNext.next)) {
          if (
            optionsOrObserverOrOnNext.error &&
            !isFunction(optionsOrObserverOrOnNext.error)
          ) {
            throw new Error(
              'Query.onSnapshot failed: Observer.error must be a valid function.'
            );
          }
          // $FlowExpectedError: Not coping with the overloaded method signature
          observer = {
            next: optionsOrObserverOrOnNext.next,
            error: optionsOrObserverOrOnNext.error,
          };
        } else {
          throw new Error(
            'Query.onSnapshot failed: Observer.next must be a valid function.'
          );
        }
      } else if (
        Object.prototype.hasOwnProperty.call(
          optionsOrObserverOrOnNext,
          'includeMetadataChanges'
        )
      ) {
        metadataChanges = optionsOrObserverOrOnNext;
        // Called with: Options, onNext, ?onError
        if (isFunction(observerOrOnNextOrOnError)) {
          if (onError && !isFunction(onError)) {
            throw new Error(
              'Query.onSnapshot failed: Third argument must be a valid function.'
            );
          }
          // $FlowExpectedError: Not coping with the overloaded method signature
          observer = {
            next: observerOrOnNextOrOnError,
            error: onError,
          };
          // Called with Options, Observer
        } else if (
          observerOrOnNextOrOnError &&
          isObject(observerOrOnNextOrOnError) &&
          observerOrOnNextOrOnError.next
        ) {
          if (isFunction(observerOrOnNextOrOnError.next)) {
            if (
              observerOrOnNextOrOnError.error &&
              !isFunction(observerOrOnNextOrOnError.error)
            ) {
              throw new Error(
                'Query.onSnapshot failed: Observer.error must be a valid function.'
              );
            }
            observer = {
              next: observerOrOnNextOrOnError.next,
              error: observerOrOnNextOrOnError.error,
            };
          } else {
            throw new Error(
              'Query.onSnapshot failed: Observer.next must be a valid function.'
            );
          }
        } else {
          throw new Error(
            'Query.onSnapshot failed: Second argument must be a function or observer.'
          );
        }
      } else {
        throw new Error(
          'Query.onSnapshot failed: First argument must be a function, observer or options.'
        );
      }
    } else {
      throw new Error(
        'Query.onSnapshot failed: Called with invalid arguments.'
      );
    }
    const listenerId = firestoreAutoId();

    const listener = nativeQuerySnapshot => {
      const querySnapshot = new QuerySnapshot(
        this._firestore,
        this,
        nativeQuerySnapshot
      );
      observer.next(querySnapshot);
    };

    // Listen to snapshot events
    SharedEventEmitter.addListener(
      getAppEventName(this._firestore, `onQuerySnapshot:${listenerId}`),
      listener
    );

    // Listen for snapshot error events
    if (observer.error) {
      SharedEventEmitter.addListener(
        getAppEventName(this._firestore, `onQuerySnapshotError:${listenerId}`),
        observer.error
      );
    }

    // Add the native listener
    getNativeModule(this._firestore).collectionOnSnapshot(
      this._referencePath.relativeName,
      this._fieldFilters,
      this._fieldOrders,
      this._queryOptions,
      listenerId,
      metadataChanges
    );

    // Return an unsubscribe method
    return this._offCollectionSnapshot.bind(this, listenerId, listener);
  }

  orderBy(
    fieldPath: string | FieldPath,
    directionStr?: QueryDirection = 'asc'
  ): Query {
    // TODO: Validation
    // validate.isFieldPath('fieldPath', fieldPath);
    // validate.isOptionalFieldOrder('directionStr', directionStr);

    if (
      this._queryOptions.startAt ||
      this._queryOptions.startAfter ||
      this._queryOptions.endAt ||
      this._queryOptions.endBefore
    ) {
      throw new Error(
        'Cannot specify an orderBy() constraint after calling ' +
          'startAt(), startAfter(), endBefore() or endAt().'
      );
    }

    const newOrder: FieldOrder = {
      direction: DIRECTIONS[directionStr],
      fieldPath: buildNativeFieldPath(fieldPath),
    };
    const combinedOrders = this._fieldOrders.concat(newOrder);
    return new Query(
      this.firestore,
      this._referencePath,
      this._fieldFilters,
      combinedOrders,
      this._queryOptions
    );
  }

  startAfter(...snapshotOrVarArgs: any[]): Query {
    const options = {
      ...this._queryOptions,
      startAfter: this._buildOrderByOption(snapshotOrVarArgs),
    };

    return new Query(
      this.firestore,
      this._referencePath,
      this._fieldFilters,
      this._fieldOrders,
      options
    );
  }

  startAt(...snapshotOrVarArgs: any[]): Query {
    const options = {
      ...this._queryOptions,
      startAt: this._buildOrderByOption(snapshotOrVarArgs),
    };

    return new Query(
      this.firestore,
      this._referencePath,
      this._fieldFilters,
      this._fieldOrders,
      options
    );
  }

  where(
    fieldPath: string | FieldPath,
    opStr: QueryOperator,
    value: any
  ): Query {
    // TODO: Validation
    // validate.isFieldPath('fieldPath', fieldPath);
    // validate.isFieldFilter('fieldFilter', opStr, value);
    const nativeValue = buildTypeMap(value);
    const newFilter: FieldFilter = {
      fieldPath: buildNativeFieldPath(fieldPath),
      operator: OPERATORS[opStr],
      value: nativeValue,
    };
    const combinedFilters = this._fieldFilters.concat(newFilter);
    return new Query(
      this.firestore,
      this._referencePath,
      combinedFilters,
      this._fieldOrders,
      this._queryOptions
    );
  }

  /**
   * INTERNALS
   */

  _buildOrderByOption(snapshotOrVarArgs: any[]) {
    // TODO: Validation
    let values;
    if (
      snapshotOrVarArgs.length === 1 &&
      snapshotOrVarArgs[0] instanceof DocumentSnapshot
    ) {
      const docSnapshot: DocumentSnapshot = snapshotOrVarArgs[0];
      values = [];
      for (let i = 0; i < this._fieldOrders.length; i++) {
        const fieldOrder = this._fieldOrders[i];
        if (
          fieldOrder.fieldPath.type === 'string' &&
          fieldOrder.fieldPath.string
        ) {
          values.push(docSnapshot.get(fieldOrder.fieldPath.string));
        } else if (fieldOrder.fieldPath.fieldpath) {
          const fieldPath = new FieldPath(...fieldOrder.fieldPath.fieldpath);
          values.push(docSnapshot.get(fieldPath));
        }
      }
    } else {
      values = snapshotOrVarArgs;
    }

    return buildNativeArray(values);
  }

  /**
   * Remove query snapshot listener
   * @param listener
   */
  _offCollectionSnapshot(listenerId: string, listener: Function) {
    getLogger(this._firestore).info('Removing onQuerySnapshot listener');
    SharedEventEmitter.removeListener(
      getAppEventName(this._firestore, `onQuerySnapshot:${listenerId}`),
      listener
    );
    SharedEventEmitter.removeListener(
      getAppEventName(this._firestore, `onQuerySnapshotError:${listenerId}`),
      listener
    );
    getNativeModule(this._firestore).collectionOffSnapshot(
      this._referencePath.relativeName,
      this._fieldFilters,
      this._fieldOrders,
      this._queryOptions,
      listenerId
    );
  }
}
