(function($, _, Backbone) {
  if (!CRM.Backbone) CRM.Backbone = {};

  /**
   * Backbone.sync provider which uses CRM.api() for I/O.
   * To support CRUD operations, model classes must be defined with a "crmEntityName" property.
   * To load collections using API queries, set the "crmCriteria" property or override the
   * method "toCrmCriteria".
   *
   * @param method Accepts normal Backbone.sync methods; also accepts "crm-replace"
   * @param model
   * @param options
   * @see tests/qunit/crm-backbone
   */
  CRM.Backbone.sync = function(method, model, options) {
    var isCollection = _.isArray(model.models);

    var apiOptions, params;
    if (isCollection) {
      apiOptions = {
        success: function(data) {
          // unwrap data
          options.success(_.toArray(data.values));
        },
        error: function(data) {
          // CRM.api displays errors by default, but Backbone.sync
          // protocol requires us to override "error". This restores
          // the default behavior.
          $().crmError(data.error_message, ts('Error'));
          options.error(data);
        }
      };
      switch (method) {
        case 'read':
          CRM.api(model.crmEntityName, model.toCrmAction('get'), model.toCrmCriteria(), apiOptions);
          break;
        // replace all entities matching "x.crmCriteria" with new entities in "x.models"
        case 'crm-replace':
          params = this.toCrmCriteria();
          params.version = 3;
          params.values = this.toJSON();
          CRM.api(model.crmEntityName, model.toCrmAction('replace'), params, apiOptions);
          break;
        default:
          apiOptions.error({is_error: 1, error_message: "CRM.Backbone.sync(" + method + ") not implemented for collections"});
          break;
      }
    } else {
      // callback options to pass to CRM.api
      apiOptions = {
        success: function(data) {
          // unwrap data
          var values = _.toArray(data.values);
          if (data.count == 1) {
            options.success(values[0]);
          } else {
            data.is_error = 1;
            data.error_message = ts("Expected exactly one response");
            apiOptions.error(data);
          }
        },
        error: function(data) {
          // CRM.api displays errors by default, but Backbone.sync
          // protocol requires us to override "error". This restores
          // the default behavior.
          $().crmError(data.error_message, ts('Error'));
          options.error(data);
        }
      };
      switch (method) {
        case 'create': // pass-through
        case 'update':
          params = model.toJSON();
          if (!params.options) params.options = {};
          params.options.reload = 1;
          if (!model._isDuplicate) {
            CRM.api(model.crmEntityName, model.toCrmAction('create'), params, apiOptions);
          } else {
            CRM.api(model.crmEntityName, model.toCrmAction('duplicate'), params, apiOptions);
          }
          break;
        case 'read':
        case 'delete':
          var apiAction = (method == 'delete') ? 'delete' : 'get';
          params = model.toCrmCriteria();
          if (!params.id) {
            apiOptions.error({is_error: 1, error_message: 'Missing ID for ' + model.crmEntityName});
            return;
          }
          CRM.api(model.crmEntityName, model.toCrmAction(apiAction), params, apiOptions);
          break;
        default:
          apiOptions.error({is_error: 1, error_message: "CRM.Backbone.sync(" + method + ") not implemented for models"});
      }
    }
  };

  /**
   * Connect a "model" class to CiviCRM's APIv3
   *
   * @code
   * // Setup class
   * var ContactModel = Backbone.Model.extend({});
   * CRM.Backbone.extendModel(ContactModel, "Contact");
   *
   * // Use class
   * c = new ContactModel({id: 3});
   * c.fetch();
   * @endcode
   *
   * @param Class ModelClass
   * @param string crmEntityName APIv3 entity name, such as "Contact" or "CustomField"
   * @see tests/qunit/crm-backbone
   */
  CRM.Backbone.extendModel = function(ModelClass, crmEntityName) {
    // Defaults - if specified in ModelClass, preserve
    _.defaults(ModelClass.prototype, {
      crmEntityName: crmEntityName,
      crmActions: {}, // map: string backboneActionName => string serverSideActionName
      crmReturn: null, // array: list of fields to return
      toCrmAction: function(action) {
        return this.crmActions[action] ? this.crmActions[action] : action;
      },
      toCrmCriteria: function() {
        var result = (this.get('id')) ? {id: this.get('id')} : {};
        if (!_.isEmpty(this.crmReturn)) {
          result.return = this.crmReturn;
        }
        return result;
      },
      duplicate: function() {
        var newModel = new ModelClass(this.toJSON());
        newModel._isDuplicate = true;
        if (newModel.setModified) newModel.setModified();
        newModel.listenTo(newModel, 'sync', function(){
          // may get called on subsequent resaves -- don't care!
          delete newModel._isDuplicate;
        });
        return newModel;
      }
    });
    // Overrides - if specified in ModelClass, replace
    _.extend(ModelClass.prototype, {
      sync: CRM.Backbone.sync
    });
  };

  /**
   * Configure a model class to track whether a model has unsaved changes.
   *
   * Methods:
   *  - setModified() - flag the model as modified/dirty
   *  - isSaved() - return true if there have been no changes to the data since the last fetch or save
   * Events:
   *  - saved(object model, bool is_saved) - triggered whenever isSaved() value would change
   *
   *  Note: You should not directly call isSaved() within the context of the success/error/sync callback;
   *  I haven't found a way to make isSaved() behave correctly within these callbacks without patching
   *  Backbone. Instead, attach an event listener to the 'saved' event.
   *
   * @param ModelClass
   */
  CRM.Backbone.trackSaved = function(ModelClass) {
    // Retain references to some of the original class's functions
    var Parent = _.pick(ModelClass.prototype, 'initialize', 'save', 'fetch');

    // Private callback
    var onSyncSuccess = function() {
      this._modified = false;
      if (this._oldModified.length > 0) {
        this._oldModified.pop();
      }
      this.trigger('saved', this, this.isSaved());
    };
    var onSaveError = function() {
      if (this._oldModified.length > 0) {
        this._modified = this._oldModified.pop();
        this.trigger('saved', this, this.isSaved());
      }
    };

    // Defaults - if specified in ModelClass, preserve
    _.defaults(ModelClass.prototype, {
      isSaved: function() {
        var result = !this.isNew() && !this.isModified();
        return result;
      },
      isModified: function() {
        return this._modified;
      },
      _saved_onchange: function(model, options) {
        if (options.parse) return;
        // console.log('change', model.changedAttributes(), model.previousAttributes());
        this.setModified();
      },
      setModified: function() {
        var oldModified = this._modified;
        this._modified = true;
        if (!oldModified) {
          this.trigger('saved', this, this.isSaved());
        }
      }
    });

    // Overrides - if specified in ModelClass, replace
    _.extend(ModelClass.prototype, {
      initialize: function(options) {
        this._modified = false;
        this._oldModified = [];
        this.listenTo(this, 'change', this._saved_onchange);
        this.listenTo(this, 'error', onSaveError);
        this.listenTo(this, 'sync', onSyncSuccess);
        if (Parent.initialize) {
          return Parent.initialize.apply(this, arguments);
        }
      },
      save: function() {
        // we'll assume success
        this._oldModified.push(this._modified);
        return Parent.save.apply(this, arguments);
      },
      fetch: function() {
        this._oldModified.push(this._modified);
        return Parent.fetch.apply(this, arguments);
      }
    });
  };

  /**
   * Configure a model class to support client-side soft deletion.
   * One can call "model.setDeleted(BOOLEAN)" to flag an entity for
   * deletion (or not) -- however, deletion will be deferred until save()
   * is called.
   *
   * Methods:
   *   setSoftDeleted(boolean) - flag the model as deleted (or not-deleted)
   *   isSoftDeleted() - determine whether model has been soft-deleted
   * Events:
   *   softDelete(model, is_deleted) -- change value of is_deleted
   *
   * @param ModelClass
   */
  CRM.Backbone.trackSoftDelete = function(ModelClass) {
    // Retain references to some of the original class's functions
    var Parent = _.pick(ModelClass.prototype, 'save');

    // Defaults - if specified in ModelClass, preserve
    _.defaults(ModelClass.prototype, {
      is_soft_deleted: false,
      setSoftDeleted: function(is_deleted) {
        if (this.is_soft_deleted != is_deleted) {
          this.is_soft_deleted = is_deleted;
          this.trigger('softDelete', this, is_deleted);
          if (this.setModified) this.setModified(); // FIXME: ugly interaction, trackSoftDelete-trackSaved
        }
      },
      isSoftDeleted: function() {
        return this.is_soft_deleted;
      }
    });

    // Overrides - if specified in ModelClass, replace
    _.extend(ModelClass.prototype, {
      save: function(attributes, options) {
        if (this.isSoftDeleted()) {
          return this.destroy(options);
        } else {
          return Parent.save.apply(this, arguments);
        }
      }
    });
  };

    /**
   * Connect a "collection" class to CiviCRM's APIv3
   *
   * Note: the collection supports a special property, crmCriteria, which is an array of
   * query options to send to the API.
   *
   * @code
   * // Setup class
   * var ContactModel = Backbone.Model.extend({});
   * CRM.Backbone.extendModel(ContactModel, "Contact");
   * var ContactCollection = Backbone.Collection.extend({
   *   model: ContactModel
   * });
   * CRM.Backbone.extendCollection(ContactCollection);
   *
   * // Use class (with passive criteria)
   * var c = new ContactCollection([], {
   *   crmCriteria: {contact_type: 'Organization'}
   * });
   * c.fetch();
   * c.get(123).set('property', 'value');
   * c.get(456).setDeleted(true);
   * c.save();
   *
   * // Use class (with active criteria)
   * var criteriaModel = new SomeModel({
   *     contact_type: 'Organization'
   * });
   * var c = new ContactCollection([], {
   *   crmCriteriaModel: criteriaModel
   * });
   * c.fetch();
   * c.get(123).set('property', 'value');
   * c.get(456).setDeleted(true);
   * c.save();
   * @endcode
   *
   *
   * @param Class CollectionClass
   * @see tests/qunit/crm-backbone
   */
  CRM.Backbone.extendCollection = function(CollectionClass) {
    var origInit = CollectionClass.prototype.initialize;
    // Defaults - if specified in CollectionClass, preserve
    _.defaults(CollectionClass.prototype, {
      crmEntityName: CollectionClass.prototype.model.prototype.crmEntityName,
      crmActions: {}, // map: string backboneActionName => string serverSideActionName
      toCrmAction: function(action) {
        return this.crmActions[action] ? this.crmActions[action] : action;
      },
      toCrmCriteria: function() {
        var result = (this.crmCriteria) ? _.extend({}, this.crmCriteria) : {};
        if (!_.isEmpty(this.crmReturn)) {
          result.return = this.crmReturn;
        } else if (this.model && !_.isEmpty(this.model.prototype.crmReturn)) {
          result.return = this.model.prototype.crmReturn;
        }
        return result;
      },

      /**
       * Get an object which represents this collection's criteria
       * as a live model. Any changes to the model will be applied
       * to the collection, and the collection will be refreshed.
       *
       * @param criteriaModelClass
       */
      setCriteriaModel: function(criteriaModel) {
        var collection = this;
        this.crmCriteria = criteriaModel.toJSON();
        this.listenTo(criteriaModel, 'change', function() {
          collection.crmCriteria = criteriaModel.toJSON();
          collection.debouncedFetch();
        });
      },

      debouncedFetch: _.debounce(function() {
        this.fetch({reset: true});
      }, 100),

      /**
       * Reconcile the server's collection with the client's collection.
       * New/modified items from the client will be saved/updated on the
       * server. Deleted items from the client will be deleted on the
       * server.
       *
       * @param Object options - accepts "success" and "error" callbacks
       */
      save: function(options) {
        if (!options) options = {};
        var collection = this;
        var success = options.success;
        options.success = function(resp) {
          // Ensure attributes are restored during synchronous saves.
          collection.reset(resp, options);
          if (success) success(collection, resp, options);
          // collection.trigger('sync', collection, resp, options);
        };
        wrapError(collection, options);

        return this.sync('crm-replace', this, options);
      }
    });
    // Overrides - if specified in CollectionClass, replace
    _.extend(CollectionClass.prototype, {
      sync: CRM.Backbone.sync,
      initialize: function(models, options) {
        if (!options) options = {};
        if (options.crmCriteriaModel) {
          this.setCriteriaModel(options.crmCriteriaModel);
        } else if (options.crmCriteria) {
          this.crmCriteria = options.crmCriteria;
        }
        if (options.crmActions) {
          this.crmActions = _.extend(this.crmActions, options.crmActions);
        }
        if (origInit) {
          return origInit.apply(this, arguments);
        }
      },
      toJSON: function() {
        var result = [];
        // filter models list, excluding any soft-deleted items
        this.each(function(model) {
          // if model doesn't track soft-deletes
          // or if model tracks soft-deletes and wasn't soft-deleted
          if (!model.isSoftDeleted || !model.isSoftDeleted()) {
            result.push(model.toJSON());
          }
        });
        return result;
      }
    });
  };

  /**
   * Find a single record, or create a new record.
   *
   * @param Object options:
   *   - CollectionClass: class
   *   - crmCriteria: Object values to search/default on
   *   - defaults: Object values to put on newly created model (if needed)
   *   - success: function(model)
   *   - error: function(collection, error)
   */
   CRM.Backbone.findCreate = function(options) {
     if (!options) options = {};
     var collection = new options.CollectionClass([], {
       crmCriteria: options.crmCriteria
     });
     collection.fetch({
      success: function(collection) {
        if (collection.length === 0) {
          var attrs = _.extend({}, collection.crmCriteria, options.defaults || {});
          var model = collection._prepareModel(attrs, options);
          options.success(model);
        } else if (collection.length == 1) {
          options.success(collection.first());
        } else {
          options.error(collection, {
            is_error: 1,
            error_message: 'Too many matches'
          });
        }
      },
      error: function(collection, errorData) {
        if (options.error) {
          options.error(collection, errorData);
        }
      }
    });
  };


  CRM.Backbone.Model = Backbone.Model.extend({
    /**
     * Return JSON version of model -- but only include fields that are
     * listed in the 'schema'.
     *
     * @return {*}
     */
    toStrictJSON: function() {
      var schema = this.schema;
      var result = this.toJSON();
      _.each(result, function(value, key) {
        if (!schema[key]) {
          delete result[key];
        }
      });
      return result;
    },
    setRel: function(key, value, options) {
      this.rels = this.rels || {};
      if (this.rels[key] != value) {
        this.rels[key] = value;
        this.trigger("rel:" + key, value);
      }
    },
    getRel: function(key) {
      return this.rels ? this.rels[key] : null;
    }
  });

  CRM.Backbone.Collection = Backbone.Collection.extend({
    /**
     * Store 'key' on this.rel and automatically copy it to
     * any children.
     *
     * @param key
     * @param value
     * @param initialModels
     */
    initializeCopyToChildrenRelation: function(key, value, initialModels) {
      this.setRel(key, value, {silent: true});
      this.on('reset', this._copyToChildren, this);
      this.on('add', this._copyToChild, this);
    },
    _copyToChildren: function() {
      var collection = this;
      collection.each(function(model) {
        collection._copyToChild(model);
      });
    },
    _copyToChild: function(model) {
      _.each(this.rels, function(relValue, relKey) {
        model.setRel(relKey, relValue, {silent: true});
      });
    },
    setRel: function(key, value, options) {
      this.rels = this.rels || {};
      if (this.rels[key] != value) {
        this.rels[key] = value;
        this.trigger("rel:" + key, value);
      }
    },
    getRel: function(key) {
      return this.rels ? this.rels[key] : null;
    }
  });

  /*
  CRM.Backbone.Form = Backbone.Form.extend({
    validate: function() {
      // Add support for form-level validators
      var errors = Backbone.Form.prototype.validate.apply(this, []) || {};
      var self = this;
      if (this.validators) {
        _.each(this.validators, function(validator) {
          var modelErrors = validator(this.getValue());

          // The following if() has been copied-pasted from the parent's
          // handling of model-validators. They are similar in that the errors are
          // probably keyed by field names... but not necessarily, so we use _others
          // as a fallback.
          if (modelErrors) {
            var isDictionary = _.isObject(modelErrors) && !_.isArray(modelErrors);

            //If errors are not in object form then just store on the error object
            if (!isDictionary) {
              errors._others = errors._others || [];
              errors._others.push(modelErrors);
            }

            //Merge programmatic errors (requires model.validate() to return an object e.g. { fieldKey: 'error' })
            if (isDictionary) {
              _.each(modelErrors, function(val, key) {
                //Set error on field if there isn't one already
                if (self.fields[key] && !errors[key]) {
                  self.fields[key].setError(val);
                  errors[key] = val;
                }

                else {
                  //Otherwise add to '_others' key
                  errors._others = errors._others || [];
                  var tmpErr = {};
                  tmpErr[key] = val;
                  errors._others.push(tmpErr);
                }
              });
            }
          }

        });
      }
      return _.isEmpty(errors) ? null : errors;
    }
  });
  */

  // Wrap an optional error callback with a fallback error event.
  var wrapError = function (model, options) {
    var error = options.error;
    options.error = function(resp) {
      if (error) error(model, resp, optio);
      model.trigger('error', model, resp, options);
    };
  };
})(CRM.$, CRM._, CRM.BB);
