import pluralize from "pluralize";
import IdMap from "./id_map";
import TransisObject from "./object";
import TransisArray from "./array";
import Validations from "./validations";
import * as attrs from "./attrs";
import * as util from "./util";

var registeredAttrs = {}, subclasses = {}, loads = [];

const NEW     = 'new';
const EMPTY   = 'empty';
const LOADED  = 'loaded';
const DELETED = 'deleted';

// Internal: Returns the `Transis.Model` subclass with the given name.
//
// name  - A string representing the name of a `Transis.Model` subclass.
// raise - A boolean indicating whether an exception should be raised if the name can't be
//         resolved (default: `true`).
//
// Returns the resolved subclass constructor function or `undefined` if a class with the given
//   name is not known.
// Throws `Error` if the `raise` argument is `true` and the name cannot not be resolved.
function resolve(name, raise = true) {
  var klass = (typeof name === 'function') ? name : subclasses[name];

  if (!klass && raise) {
    throw new Error(`Transis.Model.resolve: could not resolve subclass: \`${name}\``);
  }

  return klass;
};

// Internal: Checks to make sure the given object is of the type specified in the given
// association descriptor.
//
// Returns nothing.
// Throws `Error` if the given object isn't of the type specified in the association descriptor.
function checkAssociatedType(desc, o) {
  var klass = resolve(desc.klass);

  if (o && !(o instanceof klass)) {
    throw new Error(`${desc.debugName}: expected an object of type \`${desc.klass}\` but received \`${o}\` instead`);
  }
}

// Internal: Wraps the given promise so that subsequent chained promises are called after the next
// flush cycle has been run.
function wrapPromise(promise) {
  return promise.then(
    function(value) {
      return new Promise(
        function(resolve, reject) {
          TransisObject.delay(function() {
            resolve(value);
          });
          TransisObject._queueFlush();
        }
      );
    },
    function(reason) {
      return new Promise(
        function(resolve, reject) {
          TransisObject.delay(function() {
            reject(reason);
          });
          TransisObject._queueFlush();
        }
      );
    }
  );
}

// Internal: The overridden `_splice` method used on `hasMany` arrays. This method syncs changes to
// the array to the inverse side of the association and maintains a list of changes made.
function hasManySplice(i, n, added) {
  var owner = this.__owner__, desc = this.__desc__, inverse = desc.inverse, name = desc.name,
      removed, changes, i;

  added.forEach((o) => checkAssociatedType(desc, o));

  removed = TransisArray.prototype._splice.call(this, i, n, added);

  if (inverse && !this.__handlingInverse__) {
    removed.forEach(function(model) { model._inverseRemoved(inverse, owner); }, this);
    added.forEach(function(model) { model._inverseAdded(inverse, owner); }, this);
  }

  if (desc.owner && !loads.length) {
    changes = owner.ownChanges[name] = owner.ownChanges[name] || {added: [], removed: []};

    removed.forEach((m) => {
      if ((i = changes.added.indexOf(m)) !== -1) { changes.added.splice(i, 1); }
      else if (changes.removed.indexOf(m) === -1) { changes.removed.push(m); }
    });

    added.forEach((m) => {
      if ((i = changes.removed.indexOf(m)) !== -1) { changes.removed.splice(i, 1); }
      else if (changes.added.indexOf(m) === -1) { changes.added.push(m); }
    });

    if (!changes.added.length && !changes.removed.length) { owner._clearChange(name); }
    else { owner._setChange(name, changes); }
  }

  return removed;
}

// Internal: Called when a model is added to the inverse side of a `hasMany` association in order to
// sync the change to the `hasMany` side.
function hasManyInverseAdd(model) {
  this.__handlingInverse__ = true;
  this.push(model);
  this.__handlingInverse__ = false;
}

// Internal: Called when a model is removed from the inverse side of a `hasMany` association in
// order to sync the change to the `hasMany` side.
function hasManyInverseRemove(model) {
  var i = this.indexOf(model);

  if (i >= 0) {
    this.__handlingInverse__ = true;
    this.splice(i, 1);
    this.__handlingInverse__ = false;
  }
}

// Internal: Builds an array that manages a `hasMany` association. A hasMany array is a
// `Transis.Array` that overrides the `_splice` method in order to handle syncing the inverse side
// of the association.
function hasManyArray(owner, desc) {
  var a = TransisArray.of();
  a.proxy(owner, desc.name);
  a.__owner__      = owner;
  a.__desc__       = desc;
  a._splice        = hasManySplice;
  a._inverseAdd    = hasManyInverseAdd;
  a._inverseRemove = hasManyInverseRemove;
  return a;
}

// Internal: Provides the implementation of the query array's `query` method.
function queryArrayQuery(queryOpts = {}) {
  const opts = Object.assign({}, this.baseOpts, queryOpts);

  if (this.isPaged) { opts.page = opts.page || 1; }

  if (this.isBusy) {
    if (util.eq(opts, this.currentOpts)) { return this; }

    if (!this.__queued__) {
      this.__promise__ = this.__promise__.then(() => {
        this.query(this.__queued__);
        this.__queued__ = undefined;
        return this.__promise__;
      });
    }

    this.__queued__ = opts;
  }
  else {
    this.isBusy = true;
    this.currentOpts = opts;
    this.__promise__ = wrapPromise(this.__modelClass__._callMapper('query', [opts]).then(
      (result) => {
        const results = Array.isArray(result) ? result : result.results;
        const meta = Array.isArray(result) ? {} : result.meta;

        this.isBusy = false;
        this.meta = meta;
        this.error = undefined;

        if (!results) {
          throw new Error(`${this}#query: mapper failed to return any results`);
        }

        if (this.isPaged && typeof meta.totalCount !== 'number') {
          throw new Error(`${this}#query: mapper failed to return total count for paged query`);
        }

        try {
          const models = this.__modelClass__.loadAll(results);

          if (this.isPaged) {
            this.length = meta.totalCount;
            this.splice.apply(this, [(opts.page - 1) * this.baseOpts.pageSize, models.length].concat(models));
          }
          else {
            this.replace(models);
          }
        }
        catch (e) { console.error(e); throw e; }
      },
      (e) => {
        this.isBusy = false;
        this.error = e;
        return Promise.reject(e);
      }
    ));
  }

  return this;
}

// Internal: Provides the implementation of the query array's `then` method.
function queryArrayThen(f1, f2) { return this.__promise__.then(f1, f2); }

// Internal: Provides the implementation of the query array's `catch` method.
function queryArrayCatch(f) { return this.__promise__.catch(f); }

// Internal: Provides the implementation of the query array's `at` method.
function queryArrayAt(i) {
  const r = TransisArray.prototype.at.apply(this, arguments);
  const pageSize = this.baseOpts && this.baseOpts.pageSize;

  if (arguments.length === 1 && !r && pageSize) {
    this.query({page: Math.floor(i / pageSize) + 1});
  }

  return r;
}

// Internal: Sets the given object on a `hasOne` property.
//
// desc - An association descriptor.
// v    - The value to set.
// sync - Set to true to notify the inverse side of the association so that it can update itself.
//
// Returns nothing.
// Throws `Error` if the given object isn't of the type specified in the association descriptor.
function hasOneSet(desc, v, sync) {
  var name  = desc.name, k = `__${name}`, prev = this[k], inv = desc.inverse;

  checkAssociatedType(desc, v);

  this[k] = v;

  if (sync && inv && prev) { prev._inverseRemoved(inv, this); }
  if (sync && inv && v) { v._inverseAdded(inv, this); }

  if (prev) { prev.unproxy(this, name); }
  if (v) { v.proxy(this, name); }
}

// Internal: Callback for a successful model deletion. Updates the model's state, removes it from
// the identity map, and removes removes it from any associations its currently participating in.
function mapperDeleteSuccess() {
  IdMap.delete(this);
  this.isBusy = false;
  this.sourceState = DELETED;
  this._clearErrors();

  for (let name in this.associations) {
    let desc = this.associations[name];
    if (!desc.inverse) { continue; }
    if (desc.type === 'hasOne') {
      let m;
      if (m = this[name]) {
        m._inverseRemoved(desc.inverse, this);
      }
    }
    else if (desc.type === 'hasMany') {
      this[name].slice(0).forEach((m) => {
        m._inverseRemoved(desc.inverse, this);
      });
    }
  }
}

var Model = TransisObject.extend(function() {
  this.displayName = 'Transis.Model';

  // Public: Creates a subclass of `Transis.Model`. This method overrides the `Transis.Object.extend`
  // method in order to force `Model` subclasses to be named.
  this.extend = function(name, f) {
    if (typeof name !== 'string') { throw new Error(`${this}.extend: a name is required`); }

    var subclass = TransisObject.extend.call(this);
    subclass.displayName = name;
    subclasses[name] = subclass;

    if (typeof f === 'function') { f.call(subclass); }

    if(this.displayName !== 'Transis.Model') {
      this.subclasses = this.subclasses || [];
      this.subclasses.push(name);
    }

    return subclass;
  };

  this.resolveSubclass = function(name) {
    if(!this.subclasses || this.subclasses.indexOf(name) < 0) {
      throw new Error(`${this}.resolveSubclass: could not find subclass ${name}`);
    }

    return resolve(name);
  };

  // Public: Returns an empty instance of the model class. An empty instance contains only an id
  // and must be retrieved from the mapper before any of its attributes will be available. Since the
  // model's data mapper will likely need to perform an async action to retrieve data, this method
  // is used to construct an object that is suitable for returning from `Model.get` immediately.
  // Once the data mapper has finished loading the actual model data, the empty model will have its
  // attributes filled in.
  //
  // id - The id of the model.
  //
  // Returns a new instance of the class.
  this.empty = function(id) {
    var model = new this({id: id});
    model.sourceState = EMPTY;
    return model;
  };

  // Public: Registers a new attribute type available to be used on `Model` subclasses.
  //
  // name      - A string representing the name of the attribute type.
  // converter - A converter object or constructor function. Converter objects must implement two
  //             methods: `coerce` and `serialize`. The `coerce` method should take the given value
  //             and do its best to convert it to attribute's actual type. The `serialize` method
  //             should take the coerced value and return something suitable for JSON serialization.
  //             If a constructor function is given here, it will be instantiated once for each
  //             declared attribute of this type and will be passed the options object given to the
  //             `.attr` method.
  //
  // Returns the receiver.
  this.registerAttr = function(name, converter) {
    if (registeredAttrs[name]) {
      throw new Error(`${this}.registerAttr: an attribute with the name \`${name}\` has already been defined`);
    }

    registeredAttrs[name] = converter;

    return this;
  };

  this.registerAttr('identity', attrs.IdentityAttr);
  this.registerAttr('string', attrs.StringAttr);
  this.registerAttr('integer', attrs.IntegerAttr);
  this.registerAttr('number', attrs.NumberAttr);
  this.registerAttr('boolean', attrs.BooleanAttr);
  this.registerAttr('date', attrs.DateAttr);
  this.registerAttr('datetime', attrs.DateTimeAttr);

  // Public: Defines an attribute on the model class. Attributes are typed properties that can
  // parse/coerce raw values (say from a JSON object) into objects of the property type. Calling
  // this method will define a property on the class's `prototype` so that it is available to all
  // instances.
  //
  // Attribute values are automatically coerced to their proper type when set. The original value is
  // available in the property named "<name>BeforeCoercion".
  //
  // name - A string representing the name of the attribute.
  // type - A string representing the type of the attribute (this must have been previously
  //        registered with `Model.registerAttr`).
  // opts - An object containing properties to pass to the attribute's converter object (default:
  //        `{}`).
  //
  // Returns the receiver.
  this.attr = function(name, type, opts = {}) {
    var converter = registeredAttrs[type];

    if (!converter) {
      throw new Error(`${this}.attr: unknown attribute type: \`${type}\``);
    }

    if (typeof converter === 'function') { converter = new converter(opts); }

    this.prop(name, {
      attr: true,
      converter,
      get: function() { return this[`__${name}`]; },
      set: function(v) {
        this[`__${name}`] = converter.coerce(v);
        this[`${name}BeforeCoercion`] = v;
      },
      default: 'default' in opts ? opts.default : undefined
    });

    return this;
  };

  // Public: Defines a `hasOne` association on the receiver class. This method will generate a
  // property with the given name that can be used to get or set the associated model.
  //
  // name  - A string representing the name of the association.
  // klass - Either the constructor function of the associated model or a string containing the name
  //         of the associated constructor function. Passing a string here is useful for the case
  //         where you are defining an association where the associated constructor function has yet
  //         to be defined.
  // opts  - An object containing zero or more of the following keys (default: `{}`):
  //   inverse - Used for establishing two way associations, this is the name of the property on the
  //             associated object that points back to the receiver class.
  //
  // Returns the receiver.
  this.hasOne = function(name, klass, opts = {}) {
    var desc;

    if (!this.prototype.hasOwnProperty('associations')) {
      this.prototype.associations = Object.create(this.prototype.associations);
    }

    this.prototype.associations[name] = desc = Object.assign({}, opts, {
      type: 'hasOne', name, klass, debugName: `${this.toString()}#${name}`
    });

    if (desc.owner) {
      if (!this.prototype.hasOwnProperty('__deps__')) {
        this.prototype.__deps__ = Object.create(this.prototype.__deps__);
      }

      (this.prototype.__deps__[`${name}.errors`] =
        this.prototype.__deps__[`${name}.errors`] || []).push('errors');
      (this.prototype.__deps__[`${name}.changes`] =
        this.prototype.__deps__[`${name}.changes`] || []).push('changes');
    }

    this.prop(name, {
      get: function() { return this[`__${name}`]; },
      set: function(v) { hasOneSet.call(this, desc, v, true); }
    });

    return this;
  };

  // Public: Defines a `hasMany` association on the receiver class. This method will generate a
  // property with the given name that can be used to get or set an array of associated model
  // objects. It will also generate methods that can be used to manipulate the array. A `hasMany`
  // association with the name `widgets` would generate the following methods:
  //
  // addWidgets    - Adds one or more `Widget` models to the association array.
  // removeWidgets - Removes one ore more `Widget` models from the association array.
  // clearWidgets  - Empties the association array.
  //
  // Its important to use these generated methods to manipulate the array instead of manipulating it
  // directly since they take care of syncing changes to the inverse side of the association when
  // the association is two way.
  //
  // name  - A string representing the name of the association.
  // klass - Either the constructor function of the associated model or a string containing the name
  //         of the associated constructor function. Passing a string here is useful for the case
  //         where you are defining an association where the associated constructor function has yet
  //         to be defined.
  // opts  - (see hasOne description)
  //
  // Returns the receiver.
  this.hasMany = function(name, klass, opts = {}) {
    var k = `__${name}`, desc;

    if (!this.prototype.hasOwnProperty('associations')) {
      this.prototype.associations = Object.create(this.prototype.associations);
    }

    this.prototype.associations[name] = desc = Object.assign({}, opts, {
      type: 'hasMany', name, klass, singular: pluralize(name, 1), debugName: `${this.toString()}#${name}`
    });

    if (desc.owner) {
      if (!this.prototype.hasOwnProperty('__deps__')) {
        this.prototype.__deps__ = Object.create(this.prototype.__deps__);
      }

      (this.prototype.__deps__[`${name}.errors`] =
        this.prototype.__deps__[`${name}.errors`] || []).push('errors');
      (this.prototype.__deps__[`${name}.changes`] =
        this.prototype.__deps__[`${name}.changes`] || []).push('changes');
    }

    this.prop(name, {
      get: function() {
        if (this[k]) { return this[k]; }
        desc.klass = resolve(desc.klass);
        return this[k] = hasManyArray(this, desc);
      },
      set: function(a) { this[k].replace(a); }
    });
  };

  // Public: Add a validator for the given attribute. The validator function or method should use
  // the `Model#addError` method to record a validation error when one is detected.
  //
  // name - The name of the property to validate.
  // f    - A validator function or the name of an instance method.
  // opts - An `on:` option can be provided to specify the context in which to validate.
  //        An `if:` function can be provided to specify whether to run or not the validation. The function receives
  //        the context as the argument and must return a boolean.
  //
  // Returns the receiver.
  this.validate = function(name, f, opts = {}) {
    if (!this.prototype.hasOwnProperty('validators')) {
      this.prototype.validators = Object.create(this.prototype.validators);
    }

    (this.prototype.validators[name] = this.prototype.validators[name] || []).push({validator: f, opts});

    return this;
  };

  // Public: Loads the given model attributes into the identity map. This method should be called by
  // your data mapper(s) when new data is successfully retrieved.
  //
  // attrs - An object containing the attributes for a single model. If the attributes contain
  //         references to defined associations, those associated objects will be loaded as well.
  //
  // Returns the loaded model instance.
  // Throws `Error` if the given attributes do not contain an `id` attribute.
  this.load = function(attrs) {
    var id = attrs.id, associated = {}, model, LoadKlass, associations;

    if (id == null) {
      throw new Error(`${this}.load: an \`id\` attribute is required`);
    }

    loads.push(true);

    attrs = Object.assign({}, attrs);
    if(typeof this.getSubclass === 'function') {
      LoadKlass = this.resolveSubclass(this.getSubclass(attrs));
    }
    else {
      LoadKlass = this;
    }

    associations = LoadKlass.prototype.associations,

    model = IdMap.get(LoadKlass, id) || new LoadKlass;

    delete attrs.id;

    // extract associated attributes
    for (let name in associations) {
      let desc = associations[name];

      if (name in attrs) {
        associated[name] = attrs[name]
        delete attrs[name];
      }
      else if (desc.type === 'hasOne' && `${name}Id` in attrs) {
        associated[name] = attrs[`${name}Id`];
        delete attrs[`${name}Id`];
      }
      else if (desc.type === 'hasOne' && `${name}_id` in attrs) {
        associated[name] = attrs[`${name}_id`];
        delete attrs[`${name}_id`];
      }
      else if (desc.type === 'hasMany' && `${desc.singular}Ids` in attrs) {
        associated[name] = attrs[`${desc.singular}Ids`];
        delete attrs[`${desc.singular}Ids`];
      }
      else if (desc.type === 'hasMany' && `${desc.singular}_ids` in attrs) {
        associated[name] = attrs[`${desc.singular}_ids`];
        delete attrs[`${desc.singular}_ids`];
      }
    }

    // set non-association attributes
    model.set(attrs);

    // set id if necessary
    if (model.id === undefined) { model.id = id; }

    // load and set each association
    for (let name in associated) {
      let klass = resolve(associations[name].klass);
      let data = associated[name];

      // clear association
      if (!data) {
        model[name] = null;
        continue;
      }

      if (associations[name].type === 'hasOne') {
        let other = typeof data === 'object' ? klass.load(data) : klass.local(data);
        model[name] = other;
      }
      else if (associations[name].type === 'hasMany') {
        let others = [];
        data.forEach((o) => {
          others.push(typeof o === 'object' ? klass.load(o) : klass.local(o));
        });
        model[name] = others;
      }
    }

    model.sourceState = LOADED;
    model._clearChanges();
    model._clearErrors();

    loads.pop();

    return model;
  };

  // Public: Loads an array of attribute objects.
  //
  // objects - An array containing attribute hashes.
  //
  // Returns an array of loaded model instances.
  this.loadAll = function(objects) { return objects.map((object) => this.load(object)); };

  // Public: Creates a new query array but does not invoke its `query` method. This is useful for
  // cases where you want to initialize an empty query, but not yet run it.
  //
  // The array returned by this method is decorated with the following additional properties:
  //
  //   modelClass  - The `Transis.Model` subclass that `buildQuery` was invoked on.
  //   baseOpts    - The options passed to this method. These options will be sent with every query.
  //   currentOpts - The most recent options passed to the mapper.
  //   isBusy      - Boolean property indicating whether a query is in progress.
  //   isPaged     - Indicates whether the query array pages its results.
  //   error       - An error message set on the array when the mapper fails to fulfill its promise.
  //   meta        - Metadata provided by the mapper. May be used for paging results.
  //
  // And with these additional methods:
  //
  //   query: Execute a query by invoking the `query` method on the modelClass's mapper. This will
  //   put the array into a busy state (indicated by the `isBusy` property) until the mapper has
  //   fulfilled its promise. When the promise is successfully resolved, the returned data is loaded
  //   via `Transis.Model.loadAll` and the materialized models are replaced into the array. When the
  //   promise is rejected, the error message returned by the mapper is made available on the `error`
  //   property.
  //
  //   If the this method is called while the array is currently busy, then the call to the mapper
  //   is queued until the current query completes.
  //
  //   Passing the `pageSize` option to `buildQuery` turns the query into a paged query. Paged
  //   queries load a page of results at a time instead of simply replacing their contents on each
  //   call to `#query`. In order for this to work the mapper must resolve its promise with an
  //   object that contains a `results` key pointing to an array of records and a `meta` key that
  //   points to an object containing a `totalCount` key. The length of the query array will be set
  //   to the value of `totalCount` and the results will be spliced into the array at the offset
  //   indicated by the `page` option. Any models from pages loaded previously will remain in the
  //   array.
  //
  //   When an item is accessed via the `#at` method from a page that has yet to be fetched, the
  //   query array will automatically invoke the mapper to fetch that page. This effectively gives
  //   you a sparse array that will automatically lazily load its contents when they are needed.
  //   This behavior works very well with a virtualized list component.
  //
  //   Here is an example of the object expected from the mapper:
  //
  //   {
  //     meta: {totalCount: 321},
  //     results: [{id: 1}, {id: 2}, {id: 3}]
  //   }
  //
  //
  //   opts - An object to pass along to the mapper (default: `{}`).
  //
  //   Returns the receiver.
  //
  //   then: Registers fulfillment and rejection handlers on the latest promise object returned by
  //   the modelClass's mapper. If the `query` method has yet to be called, then the `onFulfilled`
  //   handler is invoked immediately.
  //
  //   When resolved, the `onFulfilled` handler is called with no arguments. When rejected, the
  //   `onFulfilled` handler is called with the error message from the mapper.
  //
  //   onFulfilled - A function to be invoked when the latest promise from the mapper is resolved.
  //   onRejected  - A function to be invoked when the latest promise from the mapper is rejected.
  //
  //   Returns a new `Promise` that will be resolved with the return value of `onFulfilled`.
  //
  //   catch: Registers a rejection handler on the latest promise object returned by the
  //   modelClass's mapper.
  //
  //   onRejected - A function to be invoked when the latest promise from the mapper is rejected.
  //
  //   Returns a new `Promise` that is resolved to the return value of the callback if it is called.
  //
  // baseOpts - An object to pass along to the mapper method when the query is executed with the
  //            `#query` method. Any options passed to the `#query` method will be merged in with
  //            the options given here (default: `{}`). The `pageSize` option in particular is
  //            special as it makes the resulting query array a paged query. See the discussion
  //            above.
  //
  // Returns a new `Transis.Array` decorated with the properties and methods described above.
  this.buildQuery = function(baseOpts = {}) {
    let a = TransisArray.of();

    a.__modelClass__ = this;
    a.__promise__ = wrapPromise(Promise.resolve());

    a.props({
      modelClass: {get: function() { return this.__modelClass__; }},
      baseOpts: {},
      currentOpts: {},
      isBusy: {default: false},
      isPaged: {on: ['baseOpts'], get: (baseOpts) => typeof baseOpts.pageSize === 'number'},
      error: {},
      meta: {}
    });

    a.baseOpts = baseOpts;
    a.query = queryArrayQuery;
    a.then = queryArrayThen;
    a.catch = queryArrayCatch;
    a.at = queryArrayAt;

    return a;
  };

  // Public: Creates a new `Transis.QueryArray` and invokes its `query` method with the given
  // options.
  //
  // opts - An options object to pass to the `Transis.QueryArray#query` method (default: `{}`).
  //
  // Returns a new `Transis.QueryArray`.
  this.query = function(opts = {}) { return this.buildQuery().query(opts); };

  // Public: Retrieves a model from the identity map or creates a new empty model instance. If you
  // want to get the model from the mapper, then use the `Model.get` method.
  //
  // id - The id of the model to get.
  //
  // Returns an instance of the receiver.
  this.local = function(id) { return IdMap.get(this, id) || this.empty(id); };

  // Public: Gets a model instance, either from the identity map or from the mapper. If the model
  // has already been loaded into the identity map, then it is simply returned, otherwise the data
  // mapper's `get` method will be invoked and an empty model will be returned.
  //
  // id   - The id of the model to get.
  // opts - An object containing zero or more of the following keys (default: `{}`):
  //   refresh - When true, the mapper's get method will be called regardless of whether the model
  //             exists in the identity map.
  //
  // Returns an instance of the receiver.
  this.get = function(id, opts = {}) {
    var model = this.local(id), getOpts = Object.assign({}, opts);
    delete getOpts.refresh;

    if (model.isEmpty || opts.refresh) {
      model.isBusy = true;
      model.__promise__ = wrapPromise(this._callMapper('get', [id, getOpts])
        .then((result) => {
          model.isBusy = false;
          try { this.load(result); }
          catch (e) { console.error(e); throw e; }
        }, (errors) => {
          model.isBusy = false;
          model._loadErrors(errors);
          return Promise.reject(errors);
        }));
    }

    return model;
  };

  // Public: Clears all models from the id map. This will subsequently cause the model layer to go
  // to the mapper for any model that had previously been loaded.
  this.clearIdMap = function() { IdMap.clear(); return this; };

  // Internal: Invokes the given method on the receiver's mapper, ensuring that it returns a
  // Thennable (Promise-like) object.
  //
  // method - The method to invoke on the mapper.
  // args   - An array of arguments to pass to the mapper method.
  //
  // Returns the promise returned by the mapper.
  // Throws `Error` when the mapper is not defined.
  // Throws `Error` when the mapper does not implement the given method.
  // Throws `Error` when the mapper does not return a Thennable object.
  this._callMapper = function(method, args) {
    var promise;

    if (!this.mapper) {
      throw new Error(`${this}._callMapper: no mapper defined, assign one to \`${this}.mapper\``);
    }

    if (typeof this.mapper[method] !== 'function') {
      throw new Error(`${this}._callMapper: mapper does not implement \`${method}\``);
    }

    promise = this.mapper[method].apply(this.mapper, args);

    if (!promise || typeof promise.then !== 'function') {
      throw new Error(`${this}._callMapper: mapper's \`${method}\` method did not return a Promise`);
    }

    promise.catch((error) => {
      console.warn(`${this}#_callMapper(${method}): promise rejection:`, error);
      return Promise.reject(error);
    });

    return promise;
  };

  // Internal: Holds the association descriptors created by `.hasOne` and `.hasMany`.
  this.prototype.associations = {};

  // Internal: Holds the validators registered by `.validate`.
  this.prototype.validators = {};

  // Internal: Initializes the model by setting up some internal properties.
  this.prototype.init = function(props) {
    this.sourceState = NEW;
    this.isBusy      = false;
    this.__promise__ = wrapPromise(Promise.resolve());
    this._clearChanges();

    Model.__super__.init.call(this, props);
  }

  this.prop('id', {
    get: function() { return this.__id; },
    set: function(id) {
      if (this.__id) {
        throw new Error(`${this.constructor}#id=: overwriting a model's identity is not allowed: ${this}`);
      }

      this.__id = id;

      IdMap.insert(this);
    }
  });

  this.prop('sourceState');

  this.prop('isNew', {
    on: ['sourceState'],
    get: function(sourceState) { return sourceState === NEW; }
  });

  this.prop('isEmpty', {
    on: ['sourceState'],
    get: function(sourceState) { return sourceState === EMPTY; }
  });

  this.prop('isLoaded', {
    on: ['sourceState'],
    get: function(sourceState) { return sourceState === LOADED; }
  });

  this.prop('isDeleted', {
    on: ['sourceState'],
    get: function(sourceState) { return sourceState === DELETED; }
  });

  this.prop('isBusy');

  // Public: Returns an object of changes made to properties on the receiver. For simple properties
  // and `hasOne` associations, the original value is stored. For `hasMany` associations, the added
  // and removed models are stored.
  this.prop('ownChanges', {
    get: function() { return this.__ownChanges = this.__ownChanges || {}; }
  });

  // Public: Returns a boolean indicating whether the model has any property changes or any
  // owned `hasMany` associations that have been mutated.
  this.prop('hasOwnChanges', {
    on: ['ownChanges'],
    get: function(ownChanges) { return Object.keys(ownChanges).length > 0; }
  });

  // Public: Returns an object of changes made to properties on the receiver as well as for changes
  // made to owned associated models. The keys for owned associated model changes are prefixed with
  // the association name. The keys for changes on models within an owned hasMany association are
  // prefixed with the association name and index in the array.
  //
  // Examples
  //
  //   person.changes;
  //   // {'firstName': 'Bob', 'address.street': 'maple', 'pets.1.name': 'Spike'}
  this.prop('changes', {
    on: ['ownChanges'],
    pure: false,
    get: function() {
      var changes = Object.assign({}, this.ownChanges);

      util.detectRecursion(this, function() {
        for (let name in this.associations) {
          if (!this.associations[name].owner) { continue; }

          if (this.associations[name].type === 'hasOne' && this[name]) {
            let cs = this[name].changes;
            for (let k in cs) { changes[`${name}.${k}`] = cs[k]; }
          }
          else if (this.associations[name].type === 'hasMany') {
            this[name].forEach((item, i) => {
              let cs = item.changes;
              for (let k in cs) { changes[`${name}.${i}.${k}`] = cs[k]; }
            });
          }
        }
      }.bind(this));

      return changes;
    }
  });

  // Public: Returns a boolean indicating whether the model has any changes or if any of its owned
  // associated models have changes.
  this.prop('hasChanges', {
    on: ['changes'],
    get: function(changes) { return !!Object.keys(changes).length; }
  });

  // Public: Object containing any validation errors on the model. The keys of the object are the
  // properties that have errors and the values are an array of error messages.
  this.prop('ownErrors', {
    get: function() { return this.__ownErrors = this.__ownErrors || {}; }
  });

  // Public: Returns a boolean indicating whether the model has any validation errors on its own
  // properties. Marking the model for destruction by setting the `_destroy` attribute will cause
  // this property to return `false` regardless of whether there are validation errors.
  this.prop('hasOwnErrors', {
    on: ['ownErrors', '_destroy'],
    get: function(ownErrors, _destroy) { return !_destroy && Object.keys(ownErrors).length > 0; }
  });

  // Public: Returns an object of validation errors that exist on the receiver as well as any
  // validation errors on owned associated models not marked for destruction. The keys used for
  // errors on owned associated models are similar to those returned by the `#changes` prop.
  this.prop('errors', {
    on: ['ownErrors'],
    pure: false,
    get: function() {
      var errors = Object.assign({}, this.ownErrors);

      util.detectRecursion(this, function() {
        for (let name in this.associations) {
          if (!this.associations[name].owner) { continue; }

          if (this.associations[name].type === 'hasOne' && this[name] && !this[name]._destroy) {
            let es = this[name].errors;
            for (let k in es) { errors[`${name}.${k}`] = es[k]; }
          }
          else if (this.associations[name].type === 'hasMany') {
            this[name].forEach((item, i) => {
              if (item._destroy) return;
              let es = item.errors;
              for (let k in es) { errors[`${name}.${i}.${k}`] = es[k]; }
            });
          }
        }
      }.bind(this));

      return errors;
    }
  });

  // Public: Returns a boolean indicating whether the model has any validattion errors or if any of
  // its owned associated models have validation errors.
  this.prop('hasErrors', {
    on: ['errors'],
    get: function(errors) {
      return !!Object.keys(errors).length;
    }
  });

  // Public: Used to mark a model for future destruction by the server. Owned associated models that
  // are marked for destruction will not be validated or affect the `hasErrors` property.
  this.attr('_destroy', 'boolean');

  // Public: Returns an object containing the raw values of all the receiver's attributes. Special
  // care is taken with the `_destroy` attribute, its only included if its been set.
  this.prototype.attrs = function() {
    var attrs = {};

    for (let k in this.__props__) {
      if (this.__props__[k].attr) {
        attrs[k] = this.__props__[k].converter.serialize(this[k]);
      }
    }

    if (typeof this.id !== 'undefined') { attrs.id = this.id; }
    if (attrs._destroy === undefined) { delete attrs._destroy; }

    return attrs;
  };

  // Public: Refreshes the model by getting it from the data mapper.
  //
  // opts - An object to forward along to the mapper (default: `{}`).
  //
  // Returns the receiver.
  // Throws `Error` if the receiver is not LOADED or is BUSY.
  this.prototype.get = function(opts = {}) {
    if ((!this.isLoaded && !this.isEmpty) || this.isBusy) {
      throw new Error(`${this.constructor}#get: can't get a model in the ${this.stateString()} state: ${this}`);
    }

    return this.constructor.get(this.id, Object.assign({}, opts, {refresh: true}));
  };

  // Public: Saves the model by invoking either the `create` or `update` method on the model's data
  // mapper. The `create` method is invoked if the model is in the `NEW` state and `create`
  // otherwise.
  //
  // opts - An object to forward on to the mapper (default: `{}`).
  //
  // Returns the receiver.
  // Throws `Error` if the receiver is not NEW or LOADED or is currently busy.
  this.prototype.save = function(opts = {}) {
    if ((!this.isNew && !this.isLoaded) || this.isBusy) {
      throw new Error(`${this.constructor}#save: can't save a model in the ${this.stateString()} state: ${this}`);
    }

    this.isBusy = true;

    this.__promise__ = wrapPromise(this.constructor._callMapper(this.isNew ? 'create' : 'update', [this, opts])
      .then((attrs) => {
        this.isBusy = false;
        try { this.load(attrs); }
        catch (e) { console.error(e); throw e; }
      }, (errors) => {
        this.isBusy = false;
        this._loadErrors(errors);
        return Promise.reject(errors);
      }));

    return this;
  };

  // Public: Deletes the model by passing it to the data mapper's `delete` method.
  //
  // opts - An object to forward on to the mapper (default: `{}`).
  //
  // Returns the receiver.
  // Throws `Error` if the model is currently busy.
  this.prototype.delete = function(opts = {}) {
    if (this.isDeleted) { return this; }

    if (this.isBusy) {
      throw new Error(`${this.constructor}#delete: can't delete a model in the ${this.stateString()} state: ${this}`);
    }

    if (this.isNew) {
      mapperDeleteSuccess.call(this);
    }
    else {
      this.isBusy = true;

      this.__promise__ = wrapPromise(this.constructor._callMapper('delete', [this, opts])
        .then(() => {
          mapperDeleteSuccess.call(this);
        }, (errors) => {
          this.isBusy = false;
          this._loadErrors(errors);
          return Promise.reject(errors);
        }));
    }

    return this;
  };

  // Public: Registers fulfillment and rejection handlers on the latest promise object returned by
  // the model's mapper. If the model has yet to interact with the mapper, then the `onFulfilled`
  // handler is invoked immediately.
  //
  // When resolved, the `onFulfilled` handler is called with no arguments. When rejected, the
  // `onFulfilled` handler is called with the error message from the mapper.
  //
  // onFulfilled - A function to be invoked when the latest promise from the mapper is resolved.
  // onRejected  - A function to be invoked when the latest promise from the mapper is rejected.
  //
  // Returns a new `Promise` that will be resolved with the return value of `onFulfilled`.
  this.prototype.then = function(onFulfilled, onRejected) {
    return this.__promise__.then(onFulfilled, onRejected);
  };

  // Public: Registers a rejection handler on the latest promise object returned by the model's
  // mapper.
  //
  // onRejected  - A function to be invoked when the latest promise from the mapper is rejected.
  //
  // Returns a new `Promise` that is resolved to the return value of the callback if it is called.
  this.prototype.catch = function(onRejected) {
    return this.__promise__.catch(onRejected);
  };

  // Public: Loads the given attributes into the model. This method simply ensure's that the
  // receiver has its `id` set and then delegates to `Model.load`.
  //
  // If the given attributes have an `id` that already exists in the identity map, then this method
  // will actually load the attributes onto the existing model instead of the receiver. This
  // situation may occur when you have a create or update situation on the backend.
  //
  // Returns the receiver.
  this.prototype.load = function(attrs) {
    var id = attrs.id;

    if (id == null && this.id == null) {
      throw new Error(`${this.constructor}#load: an \`id\` attribute is required`);
    }

    if (id != null && this.id != null && id !== this.id) {
      throw new Error(`${this.constructor}#load: received attributes with id \`${id}\` but model already has id \`${this.id}\``);
    }

    if (this.id == null) {
      if (!IdMap.get(this.constructor, id)) {
        this.id = id;
      }
      else {
        console.warn(`${this.constructor}#load: loading attributes that contain an id (${id}) that already exists in the identity map, the receiver will not be updated`);
      }
    }

    attrs.id = id || this.id;

    this.constructor.load(attrs);

    return this;
  };

  // Public: Returns a string representation of the model's current state.
  this.prototype.stateString = function() {
    var a = [this.sourceState.toUpperCase()];
    if (this.isBusy) { a.push('BUSY'); }
    return a.join('-');
  };

  // Public: Returns the previous for the given attribute or association name. If no change has been
  // made then `undefined` is returned.
  //
  // name - A string containing the name of an attribute or association.
  this.prototype.previousValueFor = function(name) {
    var change = this.ownChanges[name];

    if (change && this.associations[name] && this.associations[name].type === 'hasMany') {
      let previous = this[name].slice();
      let removed  = change.removed.slice();
      let added    = change.added.slice();

      removed.reverse().forEach((m) => { previous.push(m); });
      added.forEach((m) => { previous.splice(previous.indexOf(m), 1); });

      return previous;
    }
    else {
      return change;
    }
  };

  // Public: Undoes all property and owned assocation changes made to this model since it was last
  // loaded.
  //
  // opts - An object containing zero or more of the following keys:
  //   except - Either a string or an array of strings of attributes or owned association names to skip over when
  //            undoing changes.
  //   only -   Either a string or an array of strings of attributes or owned association names that limit the undoing of changes
  //            to only the provided names.
  //
  // Returns the receiver.
  this.prototype.undoChanges = function(opts = {}) {
    var associations = this.associations;

    const shouldUndo = (name) => {
      if(!opts) return true;

      if (opts.except === name) { return false; }
      if (Array.isArray(opts.except) && opts.except.indexOf(name) >= 0) { return false; }

      if (Array.isArray(opts.only)) {
        if (opts.only.indexOf(name) === -1) { return false; }
      }
      else if (opts.only && opts.only !== name) { return false; }

      return true;
    };

    for (let prop in this.ownChanges) {
      if (!shouldUndo(prop)) { continue; }

      if (associations[prop] && associations[prop].type === 'hasMany') {
        let removed = this.ownChanges[prop].removed.slice();
        let added   = this.ownChanges[prop].added.slice();

        removed.reverse().forEach((m) => { this[prop].push(m); });
        added.forEach((m) => { this[prop].splice(this[prop].indexOf(m), 1); });
      }
      else {
        this[prop] = this.ownChanges[prop];
      }
    }

    util.detectRecursion(this, function() {
      for (let name in associations) {
        let desc = associations[name];

        if (!desc.owner) { continue; }
        if (!shouldUndo(name)) { continue; }

        if (desc.type === 'hasOne') {
          this[name] && this[name].undoChanges();
        }
        else if (desc.type === 'hasMany') {
          this[name].forEach(m => m.undoChanges());
        }
      }
    }.bind(this));

    this.validate();

    return this;
  };

  // Public: Add a validation error for the given property name and type. Adding an error will cause
  // the model to enter an invalid state.
  //
  // name    - The name of the attribute or property that has a validation error.
  // message - A string containing the error message.
  //
  // Returns the receiver.
  this.prototype.addError = function(name, message) {
    this.ownErrors[name] = this.ownErrors[name] || [];

    if (this.ownErrors[name].indexOf(message) === -1) {
      this.ownErrors[name].push(message);
      this.didChange('ownErrors');
    }

    return this;
  };

  // Public: Runs registered validators for the given attribute. This will clear any existing
  // validation errors for the given attribute. The registered validators can be reduced by
  // providing an optional context.
  //
  // name - The name of the attribute to run validations for.
  // ctx  - The context in which to run validations on the given attribute. (optional)
  //
  // Returns `true` if no validation errors are found on the given attribute and `false` otherwise.
  this.prototype.validateAttr = function(name, ctx) {
    this._clearErrors(name);
    if (!this.validators[name]) { return true; }

    const validators = this.validators[name].filter( (v) => {
      const on = v.opts.on;
      const ifFunc = v.opts.if;
      if (!on && !ifFunc) { return true; }
      const onValidation = !on || (typeof on === 'string' && on === ctx);
      const ifValidation = !ifFunc || (typeof ifFunc === 'function' && !!ifFunc.call(this, ctx));
      return onValidation && ifValidation;
    });

    for (let i = 0, n = validators.length; i < n; i++) {
      let validator = validators[i].validator;

      if (typeof validator === 'function') { validator.call(this); }
      else if (typeof validator === 'string' && validator in this) { this[validator](); }
      else { throw new Error(`${this.constructor}#validateAttr: don't know how to execute validator: \`${validator}\``); }
    }

    return !(name in this.ownErrors);
  };

  // Public: Runs all registered validators for all properties and also validates owned
  // associations. Validations with no `on:` option will run no matter the context. Validations with
  // some `on:` option will only run in the specified context.
  //
  // ctx - The context in which to run validations
  //
  // Returns `true` if no validation errors are found and `false` otherwise.
  this.prototype.validate = function(ctx) {
    var associations = this.associations, failed = false;

    this._clearErrors();

    for (let name in this.validators) {
      if (!this.validateAttr(name, ctx)) { failed = true; }
    }

    util.detectRecursion(this, function() {
      for (let name in associations) {
        let desc = associations[name];

        if (!desc.owner) { continue; }

        if (desc.type === 'hasOne') {
          if (this[name] && !this[name]._destroy) {
            if (!this[name].validate(ctx)) { failed = true; }
          }
        }
        else if (desc.type === 'hasMany') {
          this[name].forEach((m) => {
            if (!m._destroy && !m.validate(ctx)) { failed = true; }
          });
        }
      }
    }.bind(this));

    return !failed;
  };

  // Public: Returns a string representation of the model.
  this.prototype.toString = function() {
    var attrs = this.attrs();

    for (let name in this.associations) {
      if (this.associations[name].type === 'hasOne') {
        if (this[name] && this[name].id) {
          attrs[name] = this[name].id;
        }
      }
      else if (this.associations[name].type === 'hasMany') {
        let ids = this[name].map(function(x) { return x.id; }).compact();
        if (ids.length) {
          attrs[name] = ids;
        }
      }
    }

    return `#<${this.constructor} (${this.stateString()}):${this.objectId} ${JSON.stringify(attrs)}>`;
  };

  // Internal: Load error message(s) received from the mapper. When passed an object, the keys of
  // the object are assumed to be attribute names and the values the error message for that
  // attribute. When given a string, an error is added to the `base` attribute.
  //
  // errors - Either an object mapping attribute names to their error messages or a string.
  //
  // Returns the receiver.
  this.prototype._loadErrors = function(errors) {
    if (typeof errors === 'object') {
      for (let k in errors) {
        if (Array.isArray(errors[k])) {
          errors[k].forEach(function(error) {
            this.addError(k, error);
          }, this);
        }
        else {
          this.addError(k, String(errors[k]));
        }
      }
    }
    else {
      this.addError('base', String(errors));
    }

    return this;
  };

  // Internal: Clears validation errors from the `errors` hash. If a name is given, only the errors
  // for the property of that name are cleared, otherwise all errors are cleared.
  this.prototype._clearErrors = function(name) {
    if (name) {
      if (name in this.ownErrors) {
        delete this.ownErrors[name];
        this.didChange('ownErrors');
      }
    }
    else if (Object.keys(this.ownErrors).length) {
      this.__ownErrors = {};
      this.didChange('ownErrors');
    }
    return this;
  };

  // Internal: Called by an inverse association when a model was removed from the inverse side.
  // Updates the local side of the association.
  //
  // name  - The local side name of the association that was modified.
  // model - The model that was removed from the inverse side.
  //
  // Returns nothing.
  this.prototype._inverseRemoved = function(name, model) {
    var desc = this.associations[name];

    if (!desc) {
      throw new Error(`${this.constructor}#inverseRemoved: unknown association \`${name}\``);
    }

    if (desc.type === 'hasOne') {
      hasOneSet.call(this, desc, undefined, false);
      this.didChange(desc.name);
    }
    else if (desc.type === 'hasMany') {
      this[desc.name]._inverseRemove(model);
    }
  };

  // Internal: Called by an inverse association when a model was added on the inverse side. Updates
  // the local side of the association.
  //
  // name  - The local side name of the association that was modified.
  // model - The model that was added on the inverse side.
  //
  // Returns nothing.
  this.prototype._inverseAdded = function(name, model) {
    var desc = this.associations[name];

    if (!desc) {
      throw new Error(`${this.constructor}#inverseAdded: unknown association \`${name}\``);
    }

    if (desc.type === 'hasOne') {
      hasOneSet.call(this, desc, model, false);
      this.didChange(desc.name);
    }
    else if (desc.type === 'hasMany') {
      this[desc.name]._inverseAdd(model);
    }
  };

  // Internal: Overrides `Transis.Object#_setProp` in order to perform change tracking.
  this.prototype._setProp = function(name, value) {
    var oldValue = Model.__super__._setProp.call(this, name, value);

    if (!this.__props__[name].attr && !this.associations[name]) { return; }
    if (this.associations[name] && !this.associations[name].owner) { return; }

    if (!(name in this.ownChanges)) {
      if (!util.eq(this[name], oldValue)) {
        this._setChange(name, oldValue);
      }
    }
    else if (util.eq(this[name], this.ownChanges[name])) {
      this._clearChange(name);
    }
  };

  // Internal: Sets the old value for the changed property of the given name.
  this.prototype._setChange = function(name, oldValue) {
    if (loads.length) { return; }
    this.ownChanges[name] = oldValue;
    this.didChange('ownChanges');
  };

  // Internal: Clears the change record for the property of the given name.
  this.prototype._clearChange = function(name) {
    if (name in this.ownChanges) {
      delete this.ownChanges[name];
      this.didChange('ownChanges');
    }
  };

  // Internal: Clears all change records.
  this.prototype._clearChanges = function() {
    if (Object.keys(this.ownChanges).length) {
      this.__ownChanges = {};
      this.didChange('ownChanges');
    }
  };
});

Model.NEW     = NEW;
Model.EMPTY   = EMPTY;
Model.LOADED  = LOADED;
Model.DELETED = DELETED;

Object.assign(Model, Validations.static);
Object.assign(Model.prototype, Validations.instance);

export default Model;


import fs from 'fs'
import { basename } from 'path'
import vcard from '../vcard'

export default {
  data,
  file,
  invalidFile,
  validFile
}

function data(relpath) {
  return fs.readFileSync(file(relpath).path, { encoding: 'utf-8' })
}

// Build a fake File from fixture path to be used by ../__mocks__/readFile.js
function file(relpath) {
  return {
    name: basename(relpath),
    path: `src/importation/__fixtures__/${relpath}`,
    size: 123,
    type: vcard.FILE_TYPES[0]
  }
}

function validFile() {
  return file('simple.vcf')
}

function invalidFile() {
  return {
    name: 'invalid',
    path: 'invalid',
    size: 123,
    type: 'invalid/type'
  }
}


/* global fetch */
import LokkaTransport from 'lokka/transport';
// In some envionment like in ReactNative, we don't need fetch at all.
// Technically, this should be handle by 'isomorphic-fetch'.
// But it's not happening. So this is the fix

const isNode = new Function('try {return this===global;}catch(e){return false;}');

let fetchUrl;
if (typeof fetch === 'function') {
  // has a native fetch implementation
  fetchUrl = fetch;
} else if (isNode()) {
  // for Node.js
  fetchUrl = require('node-fetch');
  fetchUrl.Promise = Promise;
} else {
  // for the browser
  require('whatwg-fetch');
  fetchUrl = fetch;
}

// the default error handler
function handleErrors(errors, data) {
  const message = errors[0].message;
  const error = new Error(`GraphQL Error: ${message}`);
  error.rawError = errors;
  error.rawData = data;
  throw error;
}

export class Transport extends LokkaTransport {
  constructor(endpoint, options = {}) {
    if (!endpoint) {
      throw new Error('endpoint is required!');
    }

    super();
    this._httpOptions = {
      auth: options.auth,
      headers: options.headers || {},
      credentials: options.credentials,
    };
    this.endpoint = endpoint;
    this.handleErrors = options.handleErrors || handleErrors;
  }

  _buildOptions(payload) {
    const options = {
      method: 'post',
      headers: {
        Accept: 'application/json',
        'Content-Type': 'application/json'
      },
      body: JSON.stringify(payload),
      // To pass cookies to the server. (supports CORS as well)
      credentials: 'include',
    };

    // use delete property for backward compatibility
    if (this._httpOptions.credentials === false) {
      delete options.credentials;
    }

    Object.assign(options.headers, this._httpOptions.headers);
    return options;
  }

  send(query, variables, operationName) {
    const payload = {query, variables, operationName};
    const options = this._buildOptions(payload);

    return fetchUrl(this.endpoint, options).then(response => {
      // 200 is for success
      // 400 is for bad request
      if (response.status !== 200 && response.status !== 400) {
        throw new Error(`Invalid status code: ${response.status}`);
      }

      return response.json();
    }).then(({data, errors}) => {
      if (errors) {
        this.handleErrors(errors, data);
        return null;
      }

      return data;
    });
  }
}

export default Transport;


'use strict';

const fs = require('fs-extra');
const get = require('lodash.get');
const path = require('path');
const archiver = require('archiver');
const yaml = require('js-yaml');
const yamlfiles = require('yaml-files');
const merge = require('lodash.merge');
const dotenv = require('dotenv');
const AWS = require('aws-sdk');
const execSync = require('child_process').execSync;

/**
 * Zips a list of files or directories
 * @param  {string} zipFile filename and path where the zip file is stored
 * @param  {array} srcList array of files and directories paths
 * @param  {type} dstPath for directories whether to put the directories at
 *                        root of the zip file or relative to your path on the local machine
 * @return {Promise}
 */
function zip(zipFile, srcList, dstPath) {
  if (!dstPath) {
    dstPath = false;
  }
  const output = fs.createWriteStream(zipFile);
  const archive = archiver('zip', {
    zlib: { level: 9 } // Sets the compression level.
  });

  return new Promise((resolve, reject) => {
    output.on('close', function() {
      return resolve();
    });

    archive.on('warning', function(err) {
      if (err.code === 'ENOENT') {
        console.log(err);
      }
      else {
        return reject(err);
      }
    });

    archive.on('error', function(err) {
      return reject(err);
    });

    archive.pipe(output);

    srcList.forEach((src) => {
      const stat = fs.lstatSync(src);

      if (stat.isFile()) {
        archive.file(src);
      }
      else if (stat.isDirectory() || stat.isSymbolicLink()) {
        archive.directory(src, dstPath);
      }
      else {
        return reject(new Error('Invalid path'));
      }
    });

    archive.finalize();
  });
}

/**
 * Executes shell commands synchronously and logs the
 * stdout to console.
 * @param {String} cmd  Bash command
 * @param {Boolean} [verbose=true] whether to post stdout to console
 * @return {Buffer} The command's stdout in for of Buffer
 */
function exec(cmd, verbose) {
  verbose = verbose === false ? verbose : true;

  const stdout = execSync(cmd);
  if (verbose) {
    console.log(stdout.toString());
  }
  return stdout;
}

/**
 * Updates region of an AWS configuration and point to the correct
 * of profile on ~/.aws/credentials file if necessary
 *
 * @param {String} [region='us-east-1'] AWS region
 * @param {String} [profile=null] aws credentials profile name
 */
function configureAws(region, profile, role) {
  if (region) {
    AWS.config.update({ region });
  }

  if (profile) {
    AWS.config.credentials = new AWS.SharedIniFileCredentials({
      profile
    });
  }

  if (role) {
    AWS.config.credentials = new AWS.TemporaryCredentials({
      RoleArn: role
    });
  }
}

function loadLocalEnvs(envFile) {
  let _dotenv;
  try {
    _dotenv = dotenv.parse(fs.readFileSync(envFile));
  }
  catch (e) {
    if (!e.message.includes('ENOENT')) {
      throw e;
    }
  }

  // load all env variables to an object
  return Object.assign(process.env, _dotenv);
}

function getZipName(handler) {
  return handler.split('.')[0];
}

/**
 * Checks if the input is a file, if it is a file,
 * it reads it and return the content, otherwise just pass
 * the input as an output
 *
 * @param {String} file A file path or a string
 * @returns {String} String content of a given file
 */

function fileToString(file) {
  try {
    const stat = fs.lstatSync(file);

    if (stat.isFile()) {
      const content = fs.readFileSync(file, 'utf8');
      return content.toString();
    }
  }
  catch (e) {
    if (!e.message.includes('ENOENT') && !e.message.includes('name too long, lstat')) {
      throw e;
    }
  }
  return file;
}

/**
 * Merges two yaml files. The merge is done using lodash.merge
 * and it happens recursively. Meaning that values of file2 will
 * replace values of file 1 if they have the same key.
 *
 * @param {String} file1 Yaml path to file 1 or file 1 string
 * @param {String} file2 Yaml path to file 2 or file 2 string
 * @returns {String} Merged Yaml file in string format
 */
function mergeYamls(file1, file2) {
  const obj1 = yaml.safeLoad(fileToString(file1), { schema: yamlfiles.YAML_FILES_SCHEMA });
  const obj2 = yaml.safeLoad(fileToString(file2), { schema: yamlfiles.YAML_FILES_SCHEMA });

  return yaml.safeDump(merge({}, obj1, obj2));
}

/**
 * Attempt to load a Kes override class.
 *
 * Throw the error if it is something other than that the Kes override
 * class does not exist.
 *
 * @param {string} kesFolder - The folder to look in for a Kes override class
 * @param {string} kesClass - The path/filename to look for as a Kes override class
 * @returns {Class} - A Kes override class
 */
function loadKesOverride(kesFolder, kesClass = 'kes.js') {
  let kesOverridePath = path.resolve(kesFolder, kesClass);
  let KesOverride;

  try {
    KesOverride = require(kesOverridePath);
  }
  catch (e) {
    // If the Kes override file exists, then the error occured when
    // trying to parse the file, so re-throw and prevent Kes from
    // going further.
    const fileExists = fs.existsSync(kesOverridePath);
    if (fileExists) {
      throw e;
    }

    console.log(`No Kes override found at ${kesOverridePath}, continuing`);
  }

  return KesOverride;
}

/**
 * Based on the information passed from the CLI by the commander
 * module this function determines whether to use the default Kes class
 * or use the override class provided by the user
 *
 * @param {object} options The options passed by the commander library
 * @param {Class} Kes the default kes class
 * @returns {Class} Kes class
 */
function determineKesClass(options, Kes) {
  let KesOverride;

  // If there is a kes class specified, use that
  const kesClass = get(options, 'kesClass');
  if (kesClass) {
    KesOverride = loadKesOverride(process.cwd(), kesClass);
  }
  else {
    let kesFolder;

    // Check if there is kes.js in the kes folder
    if (options.kesFolder) {
      kesFolder = options.kesFolder;
    }
    else {
      kesFolder = path.join(process.cwd(), '.kes');
    }
    KesOverride = loadKesOverride(kesFolder);

    // If the first Kes override didn't load, check if there is
    // a kes.js in the template folder.
    if (!KesOverride) {
      const template = get(options, 'template', '/path/to/nowhere');
      kesFolder = path.join(process.cwd(), template);
      KesOverride = loadKesOverride(kesFolder);
    }
  }

  return KesOverride || Kes;
}

/**
 * In case of error logs the error and exit with error 1
 * @param {Error} e error object
 */
function failure(e) {
  if (e.message) {
    console.log(e.message);
  }
  else {
    console.log(e);
  }
  process.exit(1);
}

/**
 * Exists the process when called
 */
function success() {
  process.exit(0);
}

/**
 * Discover and returns the system bucket used for deployment
 *
 * @param {Object} config - cumulus config object
 * @returns {string} name of the bucket
 */
function getSystemBucket(config) {
  let bucket = get(config, 'buckets.internal');
  if (bucket && typeof bucket === 'string') {
    return bucket;
  }

  bucket = get(config, 'system_bucket');
  if (bucket && typeof bucket === 'string') {
    return bucket;
  }
  return undefined;
}

module.exports = {
  exec,
  mergeYamls,
  fileToString,
  getZipName,
  configureAws,
  loadLocalEnvs,
  determineKesClass,
  getSystemBucket,
  failure,
  success,
  zip
};


'use strict';

const deprecate = require('deprecate');
const pLimit = require('p-limit');
const Kes = require('./src/kes');
const Config = require('./src/config');
const utils = require('./src/utils');

/**
 * Builds templates nested in the main template
 * using the specified config and cf file paths
 *
 * @param {object} config Kes config object
 * @param {object} KesClass the Kes Class
 * @param {object} options The options passed by the commander library
 * @return {Promise} returns a promise of an updated Kes config object
 */
function buildNestedCfs(config, KesClass, options) {
  const limit = pLimit(1);
  if (config.nested_templates) {
    const nested = config.nested_templates;
    console.log('Nested templates are found!');
    const ps = Object.keys(nested).map((name) => limit(() => {
      console.log(`Compiling nested template for ${name}`);

      const newOptions = Object.assign({}, options);
      newOptions.cfFile = nested[name].cfFile;
      newOptions.configFile = nested[name].configFile;

      // no templates are used in nested stacks
      delete newOptions.template;
      delete newOptions.deployment;

      // use the parent stackname
      newOptions.stack = config.stack;
      newOptions.parent = config;
      const nestedConfig = new Config(newOptions);

      // get the bucket name from the parent
      if (!nestedConfig.bucket) {
        nestedConfig.bucket = utils.getSystemBucket(config);
      }

      // add nested deployment name
      nestedConfig.nested_cf_name = name;

      const kes = new KesClass(nestedConfig);
      return kes.uploadCF().then((uri) => {
        config.nested_templates[name].url = uri;
      });
    }));
    return Promise.all(ps)
      .then(() => config)
      .catch(utils.failure);
  }
  return Promise.resolve(config);
}

/**
 * Builds, uploads and deploy a Cloudformation based on options passed
 * from the commander library
 *
 * @param {object} options Options passed by the commander library
 * @param {string} cmd the argument selected in the CLI, e.g. deploy, update, etc.
 * @return {undefined}
 */
function buildCf(options, cmd) {
  const KesClass = utils.determineKesClass(options, Kes);
  let parentConfig;
  try {
    parentConfig = new Config(options);
  }
  catch (e) {
    return Promise.reject(e);
  }

  return buildNestedCfs(parentConfig, KesClass, options).then((config) => {
    const kes = new KesClass(config);
    switch (cmd) {
      case 'create':
        deprecate('"kes cf create" command is deprecated. Use "kes cf deploy" instead');
        return kes.createStack();
      case 'update':
        deprecate('"kes cf update" command is deprecated. Use "kes cf deploy" instead');
        return kes.updateStack();
      case 'upsert':
        deprecate('"kes cf upsert" command is deprecated. Use "kes cf deploy" instead');
        return kes.upsertStack();
      case 'deploy':
        return kes.deployStack();
      case 'validate':
        return kes.validateTemplate();
      case 'compile':
        return kes.compileCF();
      case 'delete':
        return kes.deleteStack();
      default:
        console.log('Wrong choice. Accepted arguments: [create|update|upsert|deploy|validate|compile]');
    }
  });
}

/**
 * Builds and uploads a lambda function based on the options passed by the commander
 * @param {object} options Options passed by the commander library
 * @param {string} cmd the argument selected in the CLI, e.g. lambda name
 * @return {undefined}
 */
function buildLambda(options, cmd) {
  if (cmd) {
    const KesClass = utils.determineKesClass(options, Kes);
    const config = new Config(options);
    const kes = new KesClass(config);
    kes.updateSingleLambda(cmd).then(r => utils.success(r)).catch(e => utils.failure(e));
  }
  else {
    utils.failure(new Error('Lambda name is missing'));
  }
}

module.exports = {
  Kes,
  Config,
  utils,
  buildCf,
  buildLambda,
  Lambda: require('./src/lambda'),
  local: require('./src/local')
};


// source code is from here: http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/walkthrough-custom-resources-lambda-lookup-amiids.html
'use strict';

function handler(event, context, cb) {
  console.log('REQUEST RECEIVED:\n' + JSON.stringify(event));

  // For Delete requests, immediately send a SUCCESS response.
  if (event.RequestType === 'Delete') {
    sendResponse(event, context, 'SUCCESS');
    return;
  }

  const responseStatus = 'SUCCESS';
  const responseData = {};

  sendResponse(event, context, responseStatus, responseData);
}

// Send response to the pre-signed S3 URL
function sendResponse(event, context, responseStatus, responseData) {
  const responseBody = JSON.stringify({
    Status: responseStatus,
    Reason: 'See the details in CloudWatch Log Stream: ' + context.logStreamName,
    PhysicalResourceId: context.logStreamName,
    StackId: event.StackId,
    RequestId: event.RequestId,
    LogicalResourceId: event.LogicalResourceId,
    Data: responseData
  });

  console.log('RESPONSE BODY:\n', responseBody);

  const https = require('https');
  const url = require('url');

  const parsedUrl = url.parse(event.ResponseURL);
  const options = {
    hostname: parsedUrl.hostname,
    port: 443,
    path: parsedUrl.path,
    method: 'PUT',
    headers: {
      'content-type': '',
      'content-length': responseBody.length
    }
  };

  console.log('SENDING RESPONSE...\n');

  const request = https.request(options, function(response) {
    console.log('STATUS: ' + response.statusCode);
    console.log('HEADERS: ' + JSON.stringify(response.headers));
    // Tell AWS Lambda that the function execution is done
    context.done();
  });

  request.on('error', function(error) {
    console.log('sendResponse Error:' + error);
    // Tell AWS Lambda that the function execution is done
    context.done();
  });

  // write data to request body
  request.write(responseBody);
  request.end();
}

module.exports.handler = handler;


"use strict";

var defaultOptions = {
    width: 700,
    height: 520,
    menubar: "no",
    resizable: "yes",
    location: "yes",
    scrollbars: "no",
    centered: true,
};

var popupCount = 1;

/**
 * Return options converted to a string
 *
 * @param  {Object} options
 * @return {String}
 */
function optionsToString(options) {
    return Object.keys(options)
        .map(function processOption(key) {
            return key + "=" + options[key];
        })
        .join(",");
}

/**
 * Get a unique name on each call
 * @return {String}
 */
function defaultPopupName() {
    popupCount += 1;
    return "Popup " + popupCount;
}

/**
 * Convert "centered: true" key into concrete left and top arguments
 * Both can be overwritten
 *
 * @param  {Object} options
 * @return {Object}
 */
function optionsResolveCentered(options) {
    var result = options;
    var width = window.outerWidth - options.width;
    var height = window.outerHeight - options.height;

    if (options.centered) {
        result.left = options.left || Math.round(window.screenX + width / 2);
        result.top = options.top || Math.round(window.screenY + height / 2.5);
        delete result.centered;
    }
    return result;
}

/**
 * Polyfill Object.assign
 *
 * @param  {Object} target
 * @param  {Object} source1 ...
 * @return {Object}
 */
function assign(target) {
    var sources = Array.prototype.slice.call(arguments, 1);

    function assignArgument(previous, source) {
        Object.keys(source).forEach(function assignItem(key) {
            previous[key] = source[key]; // eslint-disable-line no-param-reassign
        });

        return previous;
    }

    return sources.reduce(assignArgument, target);
}

/**
 * Create a form element, add hidden inputs for all the post data
 * and post it into a newly opened popup
 *
 * @param  {String} url
 * @param  {Object} postData
 * @param  {String} name
 * @param  {Object} options
 * @return {Object}
 */
function openPopupWithPost(url, postData, name, options) {
    var form = document.createElement("form");
    var win;

    form.setAttribute("method", "post");
    form.setAttribute("action", url);
    form.setAttribute("target", name);

    Object.keys(postData).forEach(function addFormItem(key) {
        var input = document.createElement("input");
        input.type = "hidden";
        input.name = key;
        input.value = postData[key];
        form.appendChild(input);
    });

    document.body.appendChild(form);

    win = window.open("/", name, options);
    win.document.write("Loading...");

    form.submit();
    document.body.removeChild(form);

    return win;
}

/**
 * Open a popup using the first argument. Wait for it to close.
 * Returns the window object
 *
 * @param  {Function}
 * @param  {String}
 * @param  {String}
 * @param  {Object}
 * @param  {Function}
 * @return {Object}
 */
function popupExecute(execute, url, name, options, callback) {
    var popupName = name || defaultPopupName();
    var popupOptions = optionsResolveCentered(assign({}, defaultOptions, options));
    var popupCallback = callback || function noop() {};
    var optionsString = optionsToString(popupOptions);
    var win = execute(url, popupName, optionsString);
    var isMessageSent = false;
    var interval;

    function popupCallbackOnce(err, data) {
        if (!isMessageSent) {
            isMessageSent = true;
            popupCallback(err, data);
        }
    }

    function onMessage(message) {
        var data = message ? message.data : undefined;

        if (data) {
            popupCallbackOnce(undefined, data);
            window.removeEventListener("message", onMessage);
        }
    }

    window.addEventListener("message", onMessage, false);

    if (win) {
        interval = setInterval(function closePopupCallback() {
            if (win == null || win.closed) {
                setTimeout(function delayWindowClosing() {
                    clearInterval(interval);
                    popupCallbackOnce(new Error("Popup closed"));
                }, 500);
            }
        }, 100);
    } else {
        popupCallbackOnce(new Error("Popup blocked"));
    }

    return win;
}

/**
 * Open a popup using the first argument.
 * Wait for it to close and call the callback.
 * Set the options string using the options object
 * Returns the window object
 *
 * @param  {String} url
 * @param  {String} name
 * @param  {Object} options
 * @param  {Function} callback
 * @return {Object}
 */
function popup(url, name, options, callback) {
    return popupExecute(window.open, url, name, options, callback);
}

/**
 * Open a popup using the first argument.
 * Post the data into the open popup.
 * Wait for it to close and call the callback.
 * Set the options string using the options object
 * Returns the window object
 *
 * @param  {String} url
 * @param  {Object} postData
 * @param  {String} name
 * @param  {Object} options
 * @param  {Function} callback
 * @return {Object}
 */
function popupWithPost(url, postData, name, options, callback) {
    function openWithPostData(popupUrl, popupName, optionsString) {
        return openPopupWithPost(popupUrl, postData, popupName, optionsString);
    }

    return popupExecute(openWithPostData, url, name, options, callback);
}

/**
 * Return html that when executed, will trigger the popup to callback with a response
 *
 * @param  {Object}
 * @return {String}
 */
function popupResponse(data) {
    var jsonData = JSON.stringify(data);

    return (
        "<script>" +
        "window.opener.postMessage(" +
        jsonData +
        ', "*");' +
        "setTimeout(function() { window.close(); }, 50);" +
        "</script>"
    );
}

return {
    popup: popup,
    popupWithPost: popupWithPost,
    popupResponse: popupResponse,
};


/*! angular-selector - v1.6.1 - https://github.com/indrimuska/angular-selector - (c) 2015 Indri Muska - MIT */
(function (angular) {

	// Key codes
	var KEYS = { up: 38, down: 40, left: 37, right: 39, escape: 27, enter: 13, backspace: 8, delete: 46, shift: 16, leftCmd: 91, rightCmd: 93, ctrl: 17, alt: 18, tab: 9 };

	var $filter, $timeout, $window, $http, $q;

	var Selector = (function () {

		function getStyles(element) {
			return !(element instanceof HTMLElement) ? {} :
				element.ownerDocument && element.ownerDocument.defaultView.opener
					? element.ownerDocument.defaultView.getComputedStyle(element)
					: window.getComputedStyle(element);
		}

		// Selector directive
		function Selector(filter, timeout, window, http, q) {
			this.restrict   = 'EAC';
			this.replace    = true;
			this.transclude = true;
			this.scope      = {
				name:                   '@?',
				value:                  '=model',
				disabled:               '=?disable',
				disableSearch:          '=?',
				required:               '=?require',
				multiple:               '=?multi',
				placeholder:            '@?',
				valueAttr:              '@',
				labelAttr:              '@?',
				groupAttr:              '@?',
				options:                '=?',
				debounce:               '=?',
				create:                 '&?',
				limit:                  '=?',
				rtl:                    '=?',
				api:                    '=?',
				change:                 '&?',
				remote:                 '&?',
				remoteParam:            '@?',
				remoteValidation:       '&?',
				remoteValidationParam:  '@?',
				removeButton:           '=?',
				softDelete:             '=?',
				closeAfterSelection:    '=?',
				viewItemTemplate:       '=?',
				dropdownItemTemplate:   '=?',
				dropdownCreateTemplate: '=?',
				dropdownGroupTemplate:  '=?'
			};
			this.templateUrl = 'selector/selector.html';
			$filter  = filter;
			$timeout = timeout;
			$window  = window;
			$http    = http;
			$q       = q;
		}
		Selector.prototype.$inject = ['$filter', '$timeout', '$window', '$http', '$q'];
		Selector.prototype.link = function (scope, element, attrs, controller, transclude) {
			transclude(scope, function (clone, scope) {
				var filter       = $filter('filter'),
					input        = angular.element(element[0].querySelector('.selector-input input')),
					dropdown     = angular.element(element[0].querySelector('.selector-dropdown')),
					inputCtrl    = input.controller('ngModel'),
					selectCtrl   = element.find('select').controller('ngModel'),
					initDeferred = $q.defer(),
					defaults     = {
						api:                    {},
						search:                 '',
						disableSearch:          false,
						selectedValues:         [],
						highlighted:            0,
						valueAttr:              null,
						labelAttr:              'label',
						groupAttr:              'group',
						options:                [],
						debounce:               0,
						limit:                  Infinity,
						remoteParam:            'q',
						remoteValidationParam:  'value',
						removeButton:           true,
						showCreateOption:       false,
						viewItemTemplate:       'selector/item-default.html',
						dropdownItemTemplate:   'selector/item-default.html',
						dropdownCreateTemplate: 'selector/item-create.html',
						dropdownGroupTemplate:  'selector/group-default.html'
					};

				// Default attributes
				if (!angular.isDefined(scope.value) && scope.multiple) scope.value = [];
				angular.forEach(defaults, function (value, key) {
					if (!angular.isDefined(scope[key])) scope[key] = value;
				});
				angular.forEach(['name', 'valueAttr', 'labelAttr'], function (attr) {
					if (!attrs[attr]) attrs[attr] = scope[attr];
				});

				// Options' utilities
				scope.getObjValue = function (obj, path) {
					var key;
					if (!angular.isDefined(obj) || !angular.isDefined(path)) return obj;
					path = angular.isArray(path) ? path : path.split('.');
					key = path.shift();

					if (key.indexOf('[') > 0) {
						var match = key.match(/(\w+)\[(\d+)\]/);
						if (match !== null) {
							obj = obj[match[1]];
							key = match[2];
						}
					}
					return path.length === 0 ? obj[key] : scope.getObjValue(obj[key], path);
				};
				scope.setObjValue = function (obj, path, value) {
					var key;
					if (!angular.isDefined(obj)) obj = {};
					path = angular.isArray(path) ? path : path.split('.');
					key = path.shift();

					if (key.indexOf('[') > 0) {
						var match = key.match(/(\w+)\[(\d+)\]/);
						if (match !== null) {
							obj = obj[match[1]];
							key = match[2];
						}
					}
					obj[key] = path.length === 0 ? value : scope.setObjValue(obj[key], path, value);
					return obj;
				};
				scope.optionValue = function (option) {
					return scope.valueAttr == null ? option : scope.getObjValue(option, scope.valueAttr);
				};
				scope.optionEquals = function (option, value) {
					return angular.equals(scope.optionValue(option), angular.isDefined(value) ? value : scope.value);
				};

				// Value utilities
				scope.setValue = function (value) {
					if (!scope.multiple) scope.value = scope.valueAttr == null ? value : scope.getObjValue(value || {}, scope.valueAttr);
					else scope.value = scope.valueAttr == null ? (value || []) : (value || []).map(function (option) { return scope.getObjValue(option, scope.valueAttr); });
				};
				scope.hasValue = function () {
					return scope.multiple ? (scope.value || []).length > 0 : !!scope.value;
				};

				// Remote fetching
				scope.request = function (paramName, paramValue, remote, remoteParam) {
					var promise, remoteOptions = {};
					if (scope.disabled) return $q.reject();
					if (!angular.isDefined(remote))
						throw 'Remote attribute is not defined';

					scope.loading = true;
					scope.options = [];
					remoteOptions[paramName] = paramValue;
					promise = remote(remoteOptions);
					if (typeof promise.then !== 'function') {
						var settings = { method: 'GET', cache: true, params: {} };
						angular.extend(settings, promise);
						angular.extend(settings.params, promise.params);
						settings.params[remoteParam] = paramValue;
						promise = $http(settings);
					}
					promise
						.then(function (data) {
							scope.options = data.data || data;
							scope.filterOptions();
							scope.loading = false;
							initDeferred.resolve();
						}, function (error) {
							scope.loading = false;
							initDeferred.reject();
							throw 'Error while fetching data: ' + (error.message || error);
						});
					return promise;
				};
				scope.fetch = function () {
					return scope.request('search', scope.search || '', scope.remote, scope.remoteParam);
				};
				scope.fetchValidation = function (value) {
					return scope.request('value', value, scope.remoteValidation, scope.remoteValidationParam);
				};
				if (!angular.isDefined(scope.remote)) {
					scope.remote = false;
					scope.remoteValidation = false;
					initDeferred.resolve();
				} else
					if (!angular.isDefined(scope.remoteValidation))
						scope.remoteValidation = false;
				if (scope.remote)
					scope.$evalAsync(function () {
						$q.when(!scope.hasValue() || !scope.remoteValidation
							? angular.noop
							: scope.fetchValidation(scope.value)
						).then(function () {
							scope.$watch('search', function () {
								scope.$evalAsync(scope.fetch);
							});
						});
					});

				// Fill with options in the select
				scope.optionToObject = function (option, group) {
					var object  = {},
						element = angular.element(option);

					angular.forEach(option.dataset, function (value, key) {
						if (!key.match(/^\$/)) object[key] = value;
					});
					if (option.value)
						scope.setObjValue(object, scope.valueAttr || 'value', option.value);
					if (element.text())
						scope.setObjValue(object, scope.labelAttr, element.text().trim());
					if (angular.isDefined(group))
						scope.setObjValue(object, scope.groupAttr, group);
					scope.options.push(object);

					if (element.attr('selected') && (scope.multiple || !scope.hasValue()))
						if (!scope.multiple) {
							if (!scope.value) scope.value = scope.optionValue(object);
						} else {
							if (!scope.value) scope.value = [];
							scope.value.push(scope.optionValue(object));
						}
				};
				scope.fillWithHtml = function () {
					scope.options = [];
					angular.forEach(clone, function (element) {
						var tagName = (element.tagName || '').toLowerCase();

						if (tagName == 'option') scope.optionToObject(element);
						if (tagName == 'optgroup') {
							angular.forEach(element.querySelectorAll('option'), function (option) {
								scope.optionToObject(option, (element.attributes.label || {}).value);
							});
						}
					});
					scope.updateSelected();
				};

				// Initialization
				scope.initialize = function () {
					if (!scope.remote && (!angular.isArray(scope.options) || !scope.options.length))
						scope.fillWithHtml();
					if (scope.hasValue()) {
						if (!scope.multiple) {
							if (angular.isArray(scope.value)) scope.value = scope.value[0];
						} else {
							if (!angular.isArray(scope.value)) scope.value = [scope.value];
						}
						scope.updateSelected();
						scope.filterOptions();
						scope.updateValue();
					}
				};
				scope.$watch('multiple', function () {
					$timeout(scope.setInputWidth);
					initDeferred.promise.then(scope.initialize, scope.initialize);
				});

				// Dropdown utilities
				scope.dropdownPosition = function () {
					var label       = input.parent()[0],
						styles      = getStyles(label),
						marginTop   = parseFloat(styles.marginTop || 0),
						marginLeft  = parseFloat(styles.marginLeft || 0);

					dropdown.css({
						top:   (label.offsetTop + label.offsetHeight + marginTop) + 'px',
						left:  (label.offsetLeft + marginLeft) + 'px',
						width: label.offsetWidth + 'px'
					});
				};
				scope.open = function () {
					if (scope.multiple && (scope.selectedValues || []).length >= scope.limit) return;
					scope.isOpen = true;
					scope.dropdownPosition();
					scope.$evalAsync(scope.scrollToHighlighted);
				};
				scope.close = function () {
					scope.isOpen = false;
					scope.resetInput();
					if (scope.remote) $timeout(scope.fetch);
				};
				scope.decrementHighlighted = function () {
					scope.highlight(scope.highlighted - 1);
					scope.scrollToHighlighted();
				};
				scope.incrementHighlighted = function () {
					scope.highlight(scope.highlighted + 1);
					scope.scrollToHighlighted();
				};
				scope.highlight = function (index) {
					var length = scope.filteredOptions.length;
					if (length) scope.highlighted = (length + index) % length;
					if (scope.showCreateOption) {
						if (index == -1 || index == length) scope.highlighted = -1;
						if (index == -2) scope.highlighted = length - 1;
					}
				};
				scope.scrollToHighlighted = function () {
					var dd           = dropdown[0],
						index        = scope.highlighted + (scope.showCreateOption ? 1 : 0),
						option       = dd.querySelectorAll('li.selector-option')[index],
						styles       = getStyles(option),
						marginTop    = parseFloat(styles.marginTop || 0),
						marginBottom = parseFloat(styles.marginBottom || 0);

					if (!scope.filteredOptions.length || !option) return;

					if (option.offsetTop + option.offsetHeight + marginBottom > dd.scrollTop + dd.offsetHeight)
						scope.$evalAsync(function () {
							dd.scrollTop = option.offsetTop + option.offsetHeight + marginBottom - dd.offsetHeight;
						});

					if (option.offsetTop - marginTop < dd.scrollTop)
						scope.$evalAsync(function () {
							dd.scrollTop = option.offsetTop - marginTop;
						});
				};
				scope.createOption = function (value) {
					$q.when((function () {
						var option = {};
						if (angular.isFunction(scope.create)) {
							option = scope.create({ input: value });
						} else {
							scope.setObjValue(option, scope.labelAttr, value);
							scope.setObjValue(option, scope.valueAttr || 'value', value);
						}
						return option;
					})()).then(function (option) {
						scope.options.push(option);
						scope.set(option);
					});
				};
				scope.set = function (option) {
					if (scope.multiple && (scope.selectedValues || []).length >= scope.limit) return;

					if (!angular.isDefined(option))
						option = scope.filteredOptions[scope.highlighted];

					if (!scope.multiple) scope.selectedValues = [option];
					else {
						if (!scope.selectedValues)
							scope.selectedValues = [];
						if (scope.selectedValues.indexOf(option) < 0)
							scope.selectedValues.push(option);
					}
					if (!scope.multiple || scope.closeAfterSelection || (scope.selectedValues || []).length >= scope.limit) scope.close();
					scope.resetInput();
					selectCtrl.$setDirty();
				};
				scope.unset = function (index) {
					if (!scope.multiple) scope.selectedValues = [];
					else scope.selectedValues.splice(angular.isDefined(index) ? index : scope.selectedValues.length - 1, 1);
					scope.resetInput();
					selectCtrl.$setDirty();
				};
				scope.keydown = function (e) {
					switch (e.keyCode) {
						case KEYS.up:
							if (!scope.isOpen) scope.open();
							else scope.decrementHighlighted();
							e.preventDefault();
							break;
						case KEYS.down:
							if (!scope.isOpen) scope.open();
							else scope.incrementHighlighted();
							e.preventDefault();
							break;
						case KEYS.escape:
							scope.highlight(0);
							scope.close();
							break;
						case KEYS.enter:
							if (scope.isOpen) {
								if (attrs.create && scope.search && scope.highlighted == -1)
									scope.createOption(e.target.value);
								else
									if (scope.filteredOptions.length)
										scope.set();
								e.preventDefault();
							}
							break;
						case KEYS.backspace:
							if (!input.val()) {
								var search = scope.getObjValue(scope.selectedValues.slice(-1)[0] || {}, scope.labelAttr || '');
								scope.unset();
								scope.open();
								if (scope.softDelete && !scope.disableSearch)
									scope.$evalAsync(function () {
										scope.search = search;
									});
								e.preventDefault();
							}
							break;
						case KEYS.left:
						case KEYS.right:
						case KEYS.shift:
						case KEYS.ctrl:
						case KEYS.alt:
						case KEYS.tab:
						case KEYS.leftCmd:
						case KEYS.rightCmd:
							break;
						default:
							if (!scope.multiple && scope.hasValue()) {
								e.preventDefault();
							} else {
								scope.open();
								scope.highlight(0);
							}
							break;
					}
				};

				// Filtered options
				scope.inOptions = function (options, value) {
					// if options are fetched from a remote source, it's not possibile to use
					// the simplest check with native `indexOf` function, beacause every object
					// in the results array has it own new address
					if (scope.remote)
						return options.filter(function (option) { return angular.equals(value, option); }).length > 0;
					else
						return options.indexOf(value) >= 0;
				};
				scope.filterOptions = function () {
					scope.filteredOptions = filter(scope.options || [], scope.search);
					if (!angular.isArray(scope.selectedValues)) scope.selectedValues = [];
					if (scope.multiple)
						scope.filteredOptions = scope.filteredOptions.filter(function (option) {
							return !scope.inOptions(scope.selectedValues, option);
						});
					else {
						var index = scope.filteredOptions.indexOf(scope.selectedValues[0]);
						if (index >= 0) scope.highlight(index);
					}
					// show create item option
					if (scope.create) {
						scope.showCreateOption = scope.create && scope.search &&
							filter(scope.filteredOptions, scope.search, function (actual, expected) {
								return angular.isString(actual) && (actual || '').toLowerCase() == expected.toLowerCase();
							}).length == 0;
						if (scope.showCreateOption && scope.filteredOptions.length == 0 && scope.highlighted != -1) scope.highlighted = -1;
					}
				};

				// Input width utilities
				scope.measureWidth = function () {
					var width,
						styles = getStyles(input[0]),
						shadow = angular.element('');
					shadow.text(input.val() || (!scope.hasValue() ? scope.placeholder : '') || '');
					angular.element(document.body).append(shadow);
					angular.forEach(['fontFamily', 'fontSize', 'fontWeight', 'fontStyle', 'letterSpacing', 'textTransform', 'wordSpacing', 'textIndent'], function (style) {
						shadow.css(style, styles[style]);
					});
					width = shadow[0].offsetWidth;
					shadow.remove();
					return width;
				};
				scope.setInputWidth = function () {
					var width = scope.measureWidth() + 1;
					input.css('width', width + 'px');
				};
				scope.resetInput = function () {
					input.val('');
					scope.setInputWidth();
					scope.$evalAsync(function () { scope.search = ''; });
				};

				scope.$watch('[search, options, value]', function () {
					// hide selected items
					scope.filterOptions();
					$timeout(function () {
						// set input width
						scope.setInputWidth();
						// repositionate dropdown
						if (scope.isOpen) scope.dropdownPosition();
					});
				}, true);

				// Update value
				scope.updateValue = function (origin) {
					if (!angular.isDefined(origin)) origin = scope.selectedValues || [];
					scope.setValue(!scope.multiple ? origin[0] : origin);
				};
				scope.$watch('selectedValues', function (newValue, oldValue) {
					if (angular.equals(newValue, oldValue)) return;
					scope.updateValue();
					if (angular.isFunction(scope.change))
						scope.change(scope.multiple
							? { newValue: newValue, oldValue: oldValue }
							: { newValue: (newValue || [])[0], oldValue: (oldValue || [])[0] });
				}, true);
				scope.$watchCollection('options', function (newValue, oldValue) {
					if (angular.equals(newValue, oldValue) || scope.remote) return;
					scope.updateSelected();
				});

				// Update selected values
				scope.updateSelected = function () {
					if (!scope.multiple) scope.selectedValues = (scope.options || []).filter(function (option) { return scope.optionEquals(option); }).slice(0, 1);
					else
						scope.selectedValues = (scope.value || []).map(function (value) {
							return filter(scope.options, function (option) {
								return scope.optionEquals(option, value);
							})[0];
						}).filter(function (value) { return angular.isDefined(value); }).slice(0, scope.limit);
				};
				scope.$watch('value', function (newValue, oldValue) {
					if (angular.equals(newValue, oldValue)) return;
					$q.when(!scope.remote || !scope.remoteValidation || !scope.hasValue()
						? angular.noop
						: scope.fetchValidation(newValue)
					).then(function () {
						scope.updateSelected();
						scope.filterOptions();
						scope.updateValue();
					});
				}, true);

				// DOM event listeners
				angular.element(element[0].querySelector('.selector-input'))
					.on('click', function () {
						input[0].focus();
					});
				input = angular.element(element[0].querySelector('.selector-input input'))
					.on('focus', function () {
						$timeout(function () {
							scope.$apply(scope.open);
						});
					})
					.on('keydown', function (e) {
						scope.$apply(function () {
							scope.keydown(e);
						});
					})
					.on('input', function () {
						scope.setInputWidth();
					});
				dropdown
					.on('mousedown', function (e) {
						e.preventDefault();
					});
				angular.element($window)
					.on('resize', function () {
						scope.dropdownPosition();
					})
					.on('click', function (e) {
						if (angular.equals(element, e.target)) return;
						if (scope.isOpen) {
							scope.$evalAsync(scope.close);
							e.preventDefault();
						}
					});

				// Update select controller
				scope.$watch(function () { return inputCtrl.$pristine; }, function ($pristine) {
					selectCtrl[$pristine ? '$setPristine' : '$setDirty']();
				});
				scope.$watch(function () { return inputCtrl.$touched; }, function ($touched) {
					selectCtrl[$touched ? '$setTouched' : '$setUntouched']();
				});

				// Expose APIs
				angular.forEach(['open', 'close', 'fetch'], function (api) {
					scope.api[api] = scope[api];
				});
				scope.api.focus = function () {
					input[0].focus();
				};
				scope.api.set = function (value) {
					return scope.value = value;
				};
				scope.api.unset = function (value) {
					var values  = !value ? scope.selectedValues : (scope.selectedValues || []).filter(function (option) { return scope.optionEquals(option, value); }),
						indexes =
							scope.selectedValues.map(function (option, index) {
								return scope.inOptions(values, option) ? index : -1;
							}).filter(function (index) { return index >= 0; });

					angular.forEach(indexes, function (index, i) {
						scope.unset(index - i);
					});
				};
			});
		};

		return Selector;
	})();

	angular
		.module('selector', [])
		.run(['$templateCache', function ($templateCache) {
			$templateCache.put('selector/selector.html',
				'
' +
					'<select name="{{name}}" ng-hide="true" ng-required="required && !hasValue()" ' +
						'ng-model="selectedValues" multiple ng-options="option as getObjValue(option, labelAttr) for option in selectedValues"></select>' +
					'' +
						'
' +
							'
' +
								'

' +
								'
' +
									'' +
								'
' +
							'
' +
						'
' +
						'<input ng-model="search" placeholder="{{!hasValue() ? placeholder : \'\'}}" ng-model-options="{debounce: debounce}"' +
							'ng-disabled="disabled" ng-readonly="disableSearch" ng-required="required && !hasValue()" autocomplete="off">' +
						'
' +
							'' +
						'
' +
					'' +
					'
 0 || (create && search)">' +
						'

' +
						'

' +
						'

' +
					'
' +
				'
'
			);
			$templateCache.put('selector/item-create.html', 'Add ');
			$templateCache.put('selector/item-default.html', '');
			$templateCache.put('selector/group-default.html', '');
		}])
		.directive('selector', ['$filter', '$timeout', '$window', '$http', '$q', function ($filter, $timeout, $window, $http, $q) {
			return new Selector($filter, $timeout, $window, $http, $q);
		}]);

})(window.angular);


'use strict';

import parseHTML from './html';

export default function getModel(editor) {
	const syntax = getSyntax(editor);
	return parseHTML(editor, syntax);
}

export function getCachedModel(editor) {
	if (!editor.state._emmetModel) {
		editor.state._emmetModel = getModel(editor);
	}

	return editor.state._emmetModel;
}

export function resetCachedModel(editor) {
	editor.state._emmetModel = null;
}

/**
 * Returns parser-supported syntax of given editor (like 'html', 'css' etc.).
 * Returns `null` if editor’s syntax is unsupported
 * @param  {CodeMirror} editor
 * @return {String}
 */
function getSyntax(editor) {
	const mode = editor.getMode();

	if (mode.name === 'htmlmixed') {
		return 'html';
	}

	return mode.name === 'xml' ? mode.configuration : mode.name;
}