// tslint:disable only-arrow-functions
import { BehaviorSubject } from 'rxjs/BehaviorSubject';
import { Subject } from 'rxjs/Subject';

import { EventEmitter, Handler, Subscription } from './emitter';
import * as utils from './utils';

import { Chain } from './chain';
import { ChainableQuery,
         Collection,
         CollectionSearcher,
         LocalForage,
         LocalModel,
         LocalResourceFactory,
         Model,
         ModelArray,
         Plugin,
         Query,
         QueryFunction,
         ResourceFactory as IResourceFactory} from './types';

type LastRequest = (Date | number);

// The length of time since an object has been requested that we keep in persistent storage.
// In milliseconds - default to 7 days
export const defaultPStorageMaxLen = 7 * 24 * 60 * 60 * 1000;

export function createResourceFactory(LocalResourceFactory: LocalResourceFactory,
                                      localforage: LocalForage,
                                      plugins: Array<Plugin<Model>>,
                                      pStorageMaxLen: number = defaultPStorageMaxLen): IResourceFactory<Model> {

  const ResourceFactory: IResourceFactory<Model> = (url: string, rootKey: string, rootKeyPlural?: string) => {
    rootKeyPlural = rootKeyPlural || (rootKey + 's');

    // Create the local resource
    let LocalResource = LocalResourceFactory(url, rootKey, rootKeyPlural);

    // In memory resource store
    let _resources: {[id: string]: Model} = {};
    let _resFromLocal: {[$id: string]: Model} = {};

    // Create an event emitter
    let emitter = new EventEmitter();

    // Load from persistent storage (if we have something more exciting than localstorage)
    if (utils.advancedStorage(localforage)) {
      let pStorageKey = utils.persistentStorageKey(url);
      localforage.getItem(pStorageKey).then(function(data) {
        if (!(data && Array.isArray(data))) {
          return;
        }

        let now = new Date().getTime();

        data.forEach(function(datum) {
          let obj = datum.obj;
          let lastRequested = datum.lastreq;

          // Only create if the time between now and the last request is less than
          // pStorageMaxLen
          if ((now - lastRequested) <= pStorageMaxLen) {
            createFromStorage(obj, lastRequested);
          }
        });
      });
    }

    function localToRes(loc: LocalModel, id: string) {
      let res = _resFromLocal[loc.$id];

      // If we don't have a resource then create it
      if (!res) {
        res = new (<any> Resource)(loc, true);
      }

      if (id) {
        addedResource(id, res);
      }

      return res;
    }

    function toObject(this: Model) {
      return utils.toObject(this);
    }

    function reset(this: Model) {

      // If we aren't saved at all then just remove
      if (!this.$created) {
        this.$remove(true);
        return;
      }

      let newValues = this.$loc.$toObject();
      utils.removeResValues(this);
      utils.setResValues(this, newValues);
    }

    // The promise resolves once the save has been committed
    function save(this: Model) {
      let res = this;
      res.$created = true;
      return this.$loc.$save(this.$toObject()).then(function() {
        emitter.emit('updated', res);
        res.$subject.next(res);
        if (!res.$resolved) {
          res.$deferred.resolve(res);
          res.$resolved = true;
        }
        return res;
      });
    }

    function remove(this: Model, noPrompt?: boolean, skipLocal?: boolean) {
      if (!noPrompt) {
        // Add confirmation alert
        if (!window.confirm('Do you really want to delete this item?')) {
          return;
        }
      }
      this.$deleted = true;
      if (this._id) {
        removedResource(this._id, this);
      }

      let prom;
      if (skipLocal) {
        prom = Promise.resolve(true);
      } else {
        prom = this.$loc.$remove();
      }

      emitter.emit('remove', this);

      return prom;
    }

    function type() {
      return rootKey[0].toUpperCase() + rootKey.slice(1);
    }

    function collection(seeds: Model | ModelArray<Model>, relation: CollectionSearcher): Collection<Model> {
      return LocalResource.collect(Resource, seeds, relation);
    }

    function chain(origQry: ChainableQuery<Model>,
                   modelOrQryFn: (typeof Resource | QueryFunction),
                   qryFn?: QueryFunction): Query<Model> {
      return Chain(origQry, modelOrQryFn, qryFn);
    }

    function query(qry: any, limit?: number) {
      let result = LocalResource.query(qry, limit, Resource);
      emitter.emit('query', result, qry, limit);
      return result;
    }

    function get(id: string, force?: boolean): Model;
    function get(ids: string[], force?: boolean): ModelArray<Model>;
    function get(ids: (string | string[]), force?: boolean) {
      let results = LocalResource.get(ids, force, localToRes);
      emitter.emit('get', ids, results, force);
      return results;
    }

    function addedResource(id: string, res: Model) {
      let existing = _resources[id];
      _resources[id] = res;
      if (!existing) {
        emitter.emit('created', id, res);
      }
    }

    function removedResource(id: string, res: Model) {
      let existing = _resources[id];
      delete _resources[id];
      if (existing) {
        emitter.emit('deleted', id, res);
        res.$subject.next(null);
      }
    }

    function updatedLocal(res: Model, newVal: any, oldVal: any) {

      // We could have been deleted (existing oldVal, null newval)
      if (oldVal && !newVal) {
        // We have been deleted. Cleanup ourselves
        res.$remove(true, true);
      } else {

        res.$created = true;

        if (oldVal._id && (newVal._id !== oldVal._id)) {
          throw new Error('Not allowed to change id');
        }

        // Merge the objects together using the oldVal as a base, and using *our* version
        // to resolve any conflicts. We may need to put in some conflict resolution logic
        // somewhere on a case by case basis
        let merge = utils.mergeObjects(res.$toObject(), oldVal, newVal);

        // Now put back in the merge values
        utils.removeResValues(res);
        utils.setResValues(res, merge);

        // Make sure we are stored
        addedResource(res._id, res);
      }

      // Notify on the subject that we've updated
      res.$subject.next(res);
    }

    function updateServer(this: Model, val: any) {
      this.$loc.$updateServer(val);
    }

    // updates or creates a model depending on whether we know about it already. This
    // is usually used when we recieve a response with model data from the server using
    // a non-standard method
    function updateOrCreate(val: any) {
      let res = _resources[val._id];
      if (!res) {
        res = new (<any> Resource)(val, false, new Date().getTime());
      } else {
        res.$updateServer(val);
      }

      return res;
    }

    function createFromStorage(val: any, lastRequested: LastRequest) {
      let res = _resources[val._id];

      // If we do have already know about a resource then lets assume it is more up to date
      // than the storage version. If we haven't resolved yet though (or we don't yet have
      // an ID on the object) lets update our values
      if (!res) {
        res = new (<any> Resource)(val, false, lastRequested);
      } else if (!res._id || !res.$resolved) {
        res.$updateServer(val);
      }

      return res;
    }

    function refresh() {
      this.$loc.$refresh();
    }

    class Resource implements Model {
      public static get = get;
      public static query = query;
      public static chain = chain;
      public static type = type;
      public static updateOrCreate = updateOrCreate;
      public static collect = collection;

      public static dispose: {(): void};
      public static addListener: {(name: string, handler: Handler): void};
      public static emit: {(fnName: string, ...data: any[]): void};
      public static listen: {(name: string, handler: Handler): Subscription};
      public static off: {(name: string, handler: Handler): void};
      public static on: {(name: string, handler: Handler): void};
      public static once: {(name: string, handler: Handler): void};
      public static removeListener: {(name: string, handler: Handler): void};

      public _id?: string;

      public $created: boolean;
      public $deleted: boolean;
      public $promise: Promise<Resource>;
      public $subject: BehaviorSubject<Resource>;
      public $resolvedSubject: Subject<Resource>;
      public $deferred: utils.Deferred<Resource>;
      public $resolved: boolean;
      public $loc: LocalModel;
      public $id: string;

      public $save = save;
      public $remove = remove;
      public $delete = remove;
      public $type = type;
      public $reset = reset;
      public $refresh = refresh;
      public $toObject = toObject;
      public $updateServer = updateServer;

      constructor(val?: any, fromLoc?: boolean, lastRequested?: LastRequest) {
        let res = this;
        this.$deleted = false;
        this.$deferred = new utils.Deferred();
        this.$promise = this.$deferred.promise; // An initial promise for our initial
                                                // fetch or create of data
        this.$resolved = false; // Have we had an initial resolution of the promise
        this.$created = false; // Goes true on an initial save
        this.$subject = new BehaviorSubject(this);
        this.$resolvedSubject = utils.convertToResolvedSubject(this.$subject, this.$promise);

        // If we've been given values put them on
        if (val) {
          let props = fromLoc ? val.$toObject() : val;
          for (let key of Object.keys(props)) {
            res[key] = props[key];
          }
        }

        // Create the local resource
        if (fromLoc) {
          this.$loc = val;
          this.$loc.$mod = this;

          // Resolve the promise once the local copy has resolved
          this.$loc.$promise.then(function() {
            if (!res.$resolved) {
              res.$deferred.resolve(res);
              res.$resolved = true;
            }
          });
        } else if (this._id) {
          // We have an id - so we persist down
          this.$loc = new (<any> LocalResource)(val, false, this, lastRequested);

          // We immediately resolve our promise since we have data
          this.$deferred.resolve(this);
          this.$resolved = true;
        } else {
          // Don't add in the values until we save
          this.$loc = new (<any> LocalResource)(null, false, this);
        }

        this.$id = this.$loc.$id;
        _resFromLocal[this.$id] = this;

        // If we have an id then add us to the store
        if (this._id) {
          addedResource(this._id, this);
        }

        // Listen for changes on the local resource
        this.$loc.$emitter.on('update', function(newVal, oldVal) {
          updatedLocal(res, newVal, oldVal);
        });
      }
    }

    // Trigger any plugins
    for (let plugin of plugins) {
      plugin(Resource, rootKey);
    }

    inheritEventEmitter(Resource, emitter);

    return Resource;
  };

  ResourceFactory.Chain = Chain;
  return ResourceFactory;
}

function inheritEventEmitter(obj: any, emitter: EventEmitter) {
  let props = [
    'dispose',
    'addListener',
    'emit',
    'listen',
    'off',
    'on',
    'once',
    'removeListener',
  ];

  // Event emitter 'inheritance'
  for (let prop of props) {
    obj[prop] = function() {
      return emitter[prop].apply(emitter, arguments);
    };
  }
}
