// tslint:disable only-arrow-functions
import { values } from 'lodash';

import { EventEmitter } from './emitter';
import { CollectionFactory,
         CollectionSearcher,
         DatabaseFactory,
         JSONPatch,
         LocalForage,
         LocalModel,
         LocalResource as ILocalResource,
         Model,
         ModelArray,
         QueryFactory,
         Resource,
         ServerModel,
         ServerResourceFactory } from './types';
import * as utils from './utils';

interface ResourceMap {
  [id: string]: LocalModel;
}

type LastRequest = (Date | number);

const MAX_STORAGE_SIZE = 3 * 1024 * 1024; // 3MB - should fit without problems in any browser

export function createLocalResourceFactory(ServerResourceFactory: ServerResourceFactory,
                                           QueryFactory: QueryFactory,
                                           CollectionFactory: CollectionFactory<Model>,
                                           ResourceDBFactory: DatabaseFactory,
                                           localforage: LocalForage) {
  let totalStorageSize = 0;
  let totalDesiredSize = 0;
  let persistMode = 'FULL';
  let persistModeEmitter = new EventEmitter();

  function LocalResourceFactory(url: string, rootKey: string, rootKeyPlural: string) {

    let db = ResourceDBFactory();

    // Create the server resource and query
    let ServerResource = ServerResourceFactory(url, rootKey, rootKeyPlural);
    let QueryList = QueryFactory(url, rootKey, rootKeyPlural, db);
    let Collection = CollectionFactory();

    // In memory resource store
    let _resources: ResourceMap = {};
    let _internalRes: ResourceMap = {};

    let ourStorageSize = 0;
    let ourDesiredSize = 0;
    let persistModeWatching = false;

    let _lastreqs: {[id: string]: LastRequest} = {};
    let _reqs = {};

    let pStorageKey = utils.persistentStorageKey(url);
    let persistProm: Promise<any> = null;

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

    function idToModel(id: string) {
      let loc = _internalRes[id];
      return loc.$mod;
    }

    function serverTransform(serv: ServerModel, id: string, transformer: Function) {
      let res: LocalModel = _internalRes[serv.$id];

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

      if (id) {
        _resources[id] = res;
      }

      return transformer(res, id);
    }

    function get(ids: (string | string[]), force?: boolean, transform?: Function) {
      // We've requested a bunch of ids
      // We should persist the change (to offline storage) if this is the first request
      // for this (these) objects
      let now = new Date().getTime();
      let isSomeFirst = false;
      if (Array.isArray(ids)) {
        ids.forEach(function(id) {
          if (!_reqs[id]) {
            isSomeFirst = true;
          }

          _lastreqs[id] = now;
          _reqs[id] = true;
        });
      } else {
        if (!_reqs[ids]) {
          isSomeFirst = true;
        }

        _lastreqs[ids] = now;
        _reqs[ids] = true;
      }

      // If our persistMode is MIN we'll want to persist to storage here
      if (persistMode === 'MIN' && isSomeFirst) {
        persistChange();
      }

      return ServerResource.get(ids, force, function(serv: ServerModel, id: string) {
        return serverTransform(serv, id, transform);
      });
    }

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

    function query(qry: any, limit: number, Resource: Resource<Model>) {
      return QueryList(qry, limit, Resource, idToModel);
    }

    function remove(this: LocalModel, skipServer?: boolean) {
      if (this._id) {
        delete _resources[this._id];
      }

      this.$deleted = true;

      // Kick the database
      syncToStorage(this);

      // If we have been created then notify the server

      if (this.$created && !skipServer) {
        return this.$serv.$remove();
      }

      return Promise.resolve(true);
    }

    // Once we are synced with the server resource we will resolve the promise
    function save(this: LocalModel, vals: any) {
      let res = this;
      res.$created = true;

      // Only trigger the server sync once per 'tick' (so calling save() multiple times
      // has no effect)
      if (!res.$saveprom) {
        let oldData = this.$toObject();
        res.$saveprom = new Promise(resolve => {
          setTimeout(function() {
            resolve();
          });
        }).then(function () {
          res.$saveprom = null;

          // Save us to the db
          syncToStorage(res);

          let patch = utils.diff(oldData, res.$toObject());
          return syncToServer(res, patch).then(function() {
            return res;
          });
        }).then(function() {
          if (!res.$resolved) {
            // Wait for the db to sync before resolving (if the sync is outstanding)
            let dbprom = res.$dbsync ? res.$dbsync : Promise.resolve();
            dbprom.then(function() {
              res.$deferred.resolve(res);
              res.$resolved = true;
            });
          }
          return res;
        });
      }

      utils.removeResValues(res);
      utils.setResValues(res, vals);

      return res.$saveprom;
    }

    function performServerSync(res: any, patch: JSONPatch) {
      // We are about to sync. Unset the resync flag
      res.$resync = [];

      // Wait for the server to finish saving. Then check if we need to resync. This promise
      // won't resolve until there are no more resyncs to do
      return res.$serv.$save(patch).then(function() {
        if (res.$resync.length > 0) {
          return performServerSync(res, res.$resync);
        }

        return res;
      });
    }

    function syncToServer(res: any, patch: JSONPatch) {
      if (res.$sync) {
        res.$resync.push.apply(res.$resync, patch);
      } else {
        let prom = performServerSync(res, patch)['finally'](function() {
          // Once we've synced remove the $sync promise
          delete res.$sync;
        });

        res.$sync = prom;
      }

      return res.$sync;
    }

    function updatedServer(res: LocalModel, newVal: any, oldVal: any) {

      // We could have been deleted (existing oldVal, null newval)
      if (oldVal && !newVal) {
        // We have been deleted. Cleanup ourselves and pass it up the chain
        res.$emitter.emit('update', null, res.$toObject());
        res.$remove(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 preexist = res.$toObject();
        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
        _resources[res._id] = res;

        // If we've only just been given an id then store of the created time as the
        // time we were last requested (this is because the object must have just been
        // created on the server)
        if (res._id && !oldVal._id) {
          _lastreqs[res._id] = res.$createdAt;
          _reqs[res._id] = true;
        }

        // Notify that we have changed
        res.$emitter.emit('update', res.$toObject(), preexist);
        // Kick the db
        let syncprom = syncToStorage(res) || Promise.resolve();

        syncprom.then(function() {
          // We might have synced for the first time
          return res.$serv.$promise.then(function() {
            if (!res.$resolved) {
              res.$deferred.resolve(res);
              res.$resolved = true;
            }
          });
        });
      }
    }

    function performDbSync(res: LocalModel): Promise<void> {
      res.$dbresync = false;
      return db.update(res).then(function() {
        if (res.$dbresync) {
          return performDbSync(res);
        }

        return;
      });
    }

    function watchForPersistModeChanges() {
      if (persistModeWatching) {
        return;
      }

      persistModeEmitter.on('change', function(_url) {
        // If we have called this then ignore
        if (_url === url) {
          return;
        }

        // Redo the persist
        doPersist();
      });

    }

    function doPersist(): Promise<void> {
      // If we're already doing a persist or we dont have advanced storage options then
      // just return
      if (persistMode === 'NONE' || !utils.advancedStorage(localforage)) {
        return Promise.resolve();
      }

      // If this is the first time through then stick a listener on for changes
      if (!persistModeWatching) {
        watchForPersistModeChanges();
      }

      let data: Array<{lastreq: LastRequest, obj: any}> = [];

      switch (persistMode) {
        case 'FULL':
          data = values(_resources).map(function(res) {
            return {
              lastreq: _lastreqs[res._id],
              obj: res.$toObject(),
            };
          });
          break;

        case 'MIN':
          values(_resources).forEach(function(res) {
            if (_reqs[res._id]) {
              data.push({
                lastreq: _lastreqs[res._id],
                obj: res.$toObject(),
              });
            }
          });
          break;
        default:
          return Promise.resolve();
      }

      // We need to manually manage storage
      let dataStr = JSON.stringify(data);

      let newStorageSize = dataStr.length;
      let expectedSize = totalDesiredSize - ourDesiredSize + newStorageSize;

      // Do we expect to bust the max size? If so we need to change persist mode
      // and emit
      if (expectedSize > MAX_STORAGE_SIZE) {
        if (persistMode === 'FULL') {
          persistMode = 'MIN';
        } else if (persistMode === 'MIN') {
          persistMode = 'NONE';
        } else {
          // Don't know how we could get here but return just in case
          return Promise.resolve();
        }

        persistModeEmitter.emit('change', url);

        // Schedule this later
        return new Promise<void>(resolve => setTimeout(resolve)).then(doPersist);
      }

      // MODE HAS NOT CHANGED

      // Store our expected size
      totalDesiredSize = expectedSize;
      ourDesiredSize = newStorageSize;

      return localforage.setItem(pStorageKey, data).then(function() {
        totalStorageSize = totalStorageSize - ourStorageSize + newStorageSize;
        ourStorageSize = newStorageSize;
      });
    }

    function persistChange() {
      // If we're already doing a persist or we dont have advanced storage options then
      // just return
      if (persistProm || persistMode === 'NONE' || !utils.advancedStorage(localforage)) {
        return;
      }

      persistProm = new Promise(r => setTimeout(r, 500)).then(function() {
        return doPersist().then(function() {
          // Finished the persist
          persistProm = null;
        });
      });
    }

    function syncToStorage(res: LocalModel): Promise<void> {

      persistChange();

      if (res.$dbsync) {
        res.$dbresync = true;
      } else {
        let prom = performDbSync(res);

        prom.then(finishedSync, finishedSync);

        res.$dbsync = prom;
      }

      return res.$dbsync;

      function finishedSync() {
        // Whatever happens remove the $sync promise and refresh all the queries
        delete res.$dbsync;
        res.$dbresync = false;
        QueryList.refresh();
      }
    }

    function updateServer(this: LocalModel, val: any) {
      this.$serv.$updateVal(val);
    }

    function refresh(this: LocalModel) {
      this.$serv.$refresh();
    }

    function LocalResourceConstructor(val: any,
                                      fromServ?: boolean,
                                      mod?: any,
                                      lastRequested?: (Date | number)) {
      let res = this;
      this.$emitter = new EventEmitter();

      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; // Have we pushed any values down to the server yet?

      this.$createdAt = new Date().getTime();

      this.$resync = [];
      this.$saveprom = null;
      this.$deleted = false;

      // Store off the model so we can reference it later
      this.$mod = mod;

      // Used to correlate to the db objects when we don't have an _id (creating)
      this.$id = fromServ ? val.$id : utils.uuid();
      _internalRes[this.$id] = this;

      // If we've been given values put them on
      if (val) {
        let props = fromServ ? val.$toObject() : val;

        for (let key of Object.keys(props)) {
          res[key] = props[key];
        }
      }

      if (val && fromServ) {
        this.$serv = val;
      } else if (this._id) {
        this.$serv = new (<any> ServerResource)(val, this.$id);
      } else {
        // Don't add in the values until we save
        this.$serv = new (<any> ServerResource)(null, this.$id);
      }

      // If we have an id then add us to the store
      if (this._id) {
        _resources[this._id] = this;
      }

      // If we have an id and we've been passed in a last requested time then store off
      // the last requested time
      if (this._id && lastRequested) {
        // If the last requested already exists use the max
        let existing = _lastreqs[this._id] || 0;
        _lastreqs[this._id] = Math.max(lastRequested as number, existing as number);
      }

      // Listen for changes on the server
      this.$serv.$emitter.on('update', function(newVal: any, oldVal: any) {
        updatedServer(res, newVal, oldVal);
      });

      // Update us in the db
      this.$dbresync = false;

      // If it's from the server don't create it yet. Wait for the update to come (along
      // with hopefully all the data)
      if (!fromServ && val) {
        syncToStorage(this);
      }
    }

    const LocalResource: ILocalResource = LocalResourceConstructor;
    LocalResource.get = get;
    LocalResource.query = query;
    LocalResource.collect = collect;

    LocalResource.prototype.$save = save;
    LocalResource.prototype.$remove = remove;
    LocalResource.prototype.$delete = remove;
    LocalResource.prototype.$toObject = toObject;
    LocalResource.prototype.$updateServer = updateServer;
    LocalResource.prototype.$refresh = refresh;

    return LocalResource;
  }

  return LocalResourceFactory;
}
