'use strict';

import {isUndefined, isNull, isObject} from 'util';
import {Observable} from 'rxjs';
import * as mongodb from 'mongodb';
import {IQueryWhere, IQuery, Query, IQueryObject} from 'blow-query';
import {Adapter} from './Adapter'
import {
  IPersistedModelConstructor, 
  IPersistedAdapter,
  IModelMetadata
} from '../interfaces';

const DEFAULT_ID_PROPERTY_NAME = '_id';
const DEFAULT_ID_PROPERTY_TYPE = 'string';

function connect(url) {
  return Observable.create(observer => {
    mongodb.MongoClient.connect(url, (err, db) => {
      if (err) {
        observer.error(err);
      } else {
        observer.next(db);
      }
    });
  })
}

export class MongoDBAdapter extends Adapter implements IPersistedAdapter {

  protected _db: mongodb.Db;

  get idPropertyName(): string {
    return DEFAULT_ID_PROPERTY_NAME;
  }

  get idPropertyType(): any {
    return DEFAULT_ID_PROPERTY_TYPE;
  }

  protected _connect(): Observable<MongoDBAdapter> {
    return connect(MongoDBAdapter.getConnectionUrl(this._options))
      .map(db => {
        this._db = db;
        return this;
      });
  }

  protected _collection(metadata: IModelMetadata): mongodb.Collection {
    return this._db.collection(metadata.pluralName);
  }

  protected _prepareQuery(query: IQuery | IQueryObject): IQueryObject {
    if (query) {
      if (query instanceof Query) {
        query = (<IQuery>query).toJSON();
      }
    }
    return <IQueryObject>query;
  }

  count(metadata: IModelMetadata, where?: IQueryWhere): Observable<number> {
    return Observable.from(this._collection(metadata).count(where));
  }

  create(metadata: IModelMetadata, data: any): Observable<any> {
    return Observable.from(this._collection(metadata).insertOne(MongoDBAdapter.toDB(metadata, data)))
      .map(result => MongoDBAdapter.fromDB(metadata, result['ops'][0]));
  }

  destroy(metadata: IModelMetadata, where?: IQueryWhere): Observable<number> {
    return Observable.from(this._collection(metadata).deleteMany(where))
      .map(result => result['deletedCount']);
  }

  destroyById(metadata: IModelMetadata, id: any): Observable<boolean> {
    return this.destroy(metadata, MongoDBAdapter.buildWhereWithId(metadata, id)).map(count => !!count);
  }

  exists(metadata: IModelMetadata, id: any): Observable<boolean> {
    return this.findById(metadata, id).map(row => !!row);
  }

  //
  find(metadata: IModelMetadata, query?: IQuery | IQueryObject): Observable<any> {
    const q: IQueryObject = this._prepareQuery(query);
    let exec = this._collection(metadata).find(q.where)
 
     if (q.limit) {
       exec = exec.limit(q.limit);
     }
     if (q.skip) {
       exec = exec.skip(q.skip);
     }
     if(q.sort) {
       exec = exec.sort(q.sort);
     }
    return Observable.from(exec.toArray())
      .mergeMap(rows => Observable.from(rows))
      .map(row => MongoDBAdapter.fromDB(metadata, row));
  }

  findOne(metadata: IModelMetadata, query?: IQuery | IQueryObject): Observable<any> {
    query = this._prepareQuery(query);
    query['limit'] = 1;    
    return this.find(metadata, query);
  }

  findById(metadata: IModelMetadata, id: any): Observable<any> {
    const where = MongoDBAdapter.buildWhereWithId(metadata, id);
    return this.find(metadata, {where, limit: 1});
  }

  findOrCreate(metadata: IModelMetadata, where: IQueryWhere, data: any): Observable<any> {
    return Observable.create(observer => {
      const emit = row => {
        observer.next(row);
        observer.complete();
      }
      this.findOne(metadata, {where})
        .subscribe(emit, err => observer.error(err), () => {
          this.create(metadata, MongoDBAdapter.toDB(metadata, data))
            .subscribe(emit);
        });          
      });
  }

  update(metadata: IModelMetadata, where: IQueryWhere, data: any): Observable<number> {
    return Observable.from(this._collection(metadata).updateMany(where, {$set: data}))
      .map(result => result['modifiedCount']);
  }

  updateOrCreate(metadata: IModelMetadata, data: any): Observable<any> {
    const idName = metadata.idProperty.name;
    if(data[idName]) {
      return this.exists(metadata, data[idName]).mergeMap(exists => {
        if(exists) {
          return this.update(metadata, {_id: data[idName]}, data).mapTo(data);
        } else {
          return this.create(metadata, data);
        }
      });
    } else {
      return this.create(metadata, data);
    }
  }

  static toDB(metadata: IModelMetadata, data: any): any {
    const idName = metadata.idProperty.name;
    const idValue = data[idName];

    if (isNull(idValue)) {
      delete data[idName];
    } else {
      data[DEFAULT_ID_PROPERTY_NAME] = this.buildId(idValue);
      if (idName !== DEFAULT_ID_PROPERTY_NAME) {
        delete data[idName];
      }
    }
    return data;
  }

  static fromDB(metadata: IModelMetadata, data: any): any {
    const idName = metadata.idProperty.name;
    if (!data) {
      return null;
    }
    if(data[DEFAULT_ID_PROPERTY_NAME]) {
      data[idName] = data[DEFAULT_ID_PROPERTY_NAME].toHexString();
      if (idName !== DEFAULT_ID_PROPERTY_NAME) {
        delete data[DEFAULT_ID_PROPERTY_NAME];
      }
    }
    
    return data;
  }

  static buildWhereWithId(metadata: IModelMetadata, id): { [key: string]: any } {
    const idKey = metadata.idProperty.name;
    const where = {};
    where[idKey] = id;
    return this.buildWhere(metadata, where);
  }

  static buildWhere(metadata: IModelMetadata, where: {[key: string]: any}): {[key: string]: any} {
    where = where || {};
    const idKey = metadata.idProperty.name;

    return Object.keys(where).reduce((w, k) => {
      if (k === idKey) {
        w[DEFAULT_ID_PROPERTY_NAME] = this.buildId(where[k]);
        if (idKey !== DEFAULT_ID_PROPERTY_NAME) {
          delete w[idKey];
        }
      } else {
        w[k] = where[k];
      }
      return w;
    }, {});
  }

  static buildId(id): mongodb.ObjectID {
    if (id instanceof mongodb.ObjectID) {
      return id;
    }
    return new mongodb.ObjectID(id);
  }

  static getConnectionUrl(options): string {
    if (!isUndefined(options.url)) {
      return options.url;
    } else {
      const auth = (options.user && options.password) ? [options.user, options.password].join(':') : '';
      return 'mongodb://' + (auth ? auth + '@' : '') + options.host + ':' + options.port + '/' + options.dbname;
    }
  }
}