Source: src/js/models/metadata/eml211/EMLMethods.js

/* global define */
define(['jquery',
    'underscore',
    'backbone',
    'models/DataONEObject',
    'models/metadata/eml/EMLMethodStep',
    'models/metadata/eml211/EMLText'],
    function($, _, Backbone, DataONEObject, EMLMethodStep, EMLText) {

  /**
  * @class EMLMethods
  * @classdesc Represents the EML Methods module. The methods field documents scientific methods
              used in the collection of this dataset.  It includes information
              on items such as tools, instrument calibration and
              software.
  * @classcategory Models/Metadata/EML211
  * @extends Backbone.Model
  */
  var EMLMethods = Backbone.Model.extend(
    /** @lends EMLMethods.prototype */{

    /**
    * The default values of this model that are get() or set()
    * @returns {object}
    * @property {string} objectXML The original XML snippet string from the EML XML
    * @property {Element} objectDOM The original XML snippet as an Element
    * @property {EMLMethodStep[]} methodSteps An array of EMLMethodSteps that describe the scientific method steps
    * @property {EMLText} studyExtentDescription The field studyExtent represents both a
                  specific sampling area and the sampling frequency (temporal
                  boundaries, frequency of occurrence).
    * @property {EMLText} samplingDescription The samplingDescription field allows for a
                  text-based/human readable description of the sampling
                  procedures used in the research project.
    */
    defaults: function(){
      return {
        objectXML: null,
        objectDOM: null,
        methodSteps: [],
        studyExtentDescription: null,
        samplingDescription: null
      }
    },

    initialize: function(attributes){
      attributes = attributes || {};

      if(attributes.objectDOM){
        this.set(this.parse(attributes.objectDOM))
      }
      else if(attributes.objectXML){
        let objectDOM = $.parseHTML(attributes.objectXML)[0];
        this.set("objectDOM", objectDOM);
        this.set(this.parse(objectDOM))
      }
      else{
        //Create the custom method steps and add to the step list
        let customMethodSteps = this.createCustomMethodSteps();
        this.set("methodSteps", customMethodSteps);
      }

      //specific attributes to listen to
      this.on("change:methodStepDescription change:studyExtentDescription change:samplingDescription",
          this.trickleUpChange);

    },

    /**
     * Maps the lower-case EML node names (valid in HTML DOM) to the camel-cased EML node names (valid in EML).
     * Used during parse() and serialize()
     * @returns {object}
     */
    nodeNameMap: function(){
      return _.extend(EMLMethodStep.prototype.nodeNameMap(), {
        "methodstep" : "methodStep",
        "datasource" : "dataSource",
        "studyextent" : "studyExtent",
        "spatialsamplingunits" : "spatialSamplingUnits",
        "referencedentityid" : "referencedEntityId",
        "qualitycontrol" : "qualityControl"
      })
    },

    parse: function(objectDOM) {
      var modelJSON = {};

      if (!objectDOM) var objectDOM = this.get("objectDOM");

      var model = this;

      //Create the custom method steps
      let customMethodSteps = this.createCustomMethodSteps();

      //Create new EMLMethodStep models for the method steps
      let allMethodSteps = _.map($(objectDOM).find('methodstep'), function(el, i) {
                              return new EMLMethodStep({
                                objectDOM: el
                               });
                            }),
          //Get the custom IDs for each method step, if there any
          allMethodStepIDs = _.compact(allMethodSteps.map(step => { return step.get("customMethodID") }));

      //Filter out any custom method steps that we already created from the DOM
      customMethodSteps = customMethodSteps.filter(step => { return !allMethodStepIDs.includes(step.get("customMethodID")) });

      //Combine the parsed method steps and the default custom method steps
      allMethodSteps = allMethodSteps.concat(customMethodSteps);

      //Save the method steps to this model
      modelJSON.methodSteps = allMethodSteps;

      if ($(objectDOM).find('sampling studyextent description').length > 0) {
        modelJSON.studyExtentDescription = new EMLText({
          objectDOM: $(objectDOM).find('sampling studyextent description').get(0),
          type: 'description',
          parentModel: model
        });
      }

      if ($(objectDOM).find('sampling samplingdescription').length > 0) {
        modelJSON.samplingDescription = new EMLText({
          objectDOM: $(objectDOM).find('sampling samplingdescription').get(0),
          type: 'samplingDescription',
          parentModel: model
         });
       }

      return modelJSON;
    },

    serialize: function(){
      var objectDOM = this.updateDOM();

      if(!objectDOM)
        return "";

      var xmlString = objectDOM.outerHTML;

      //Camel-case the XML
      xmlString = this.formatXML(xmlString);

      return xmlString;
    },

    /**
     * Makes a copy of the original XML DOM and updates it with the new values from the model.
     */
    updateDOM: function(){
      var objectDOM;

      if (this.get("objectDOM")) {
        objectDOM = this.get("objectDOM").cloneNode(true);
      } else {
        objectDOM = $(document.createElement("methods"));
      }

      objectDOM = $(objectDOM);

      try{
        var methodStepsFromModel = this.get('methodSteps'),
            regularMethodSteps = this.getNonCustomSteps(),
            customMethodSteps = _.difference(methodStepsFromModel, regularMethodSteps),
            sortedCustomMethodSteps = [],
            methodStepsFromDOM   = $(objectDOM).find("methodstep");

        //Detach the existing method steps from the DOM first
        methodStepsFromDOM.detach();

        try{

          //Sort the custom method steps to match the app config order
          let configCustomMethods = _.clone(MetacatUI.appModel.get("customEMLMethods") || []);
          if( configCustomMethods.length ){
            configCustomMethods.forEach( customOptions => {
              let matchingStep = customMethodSteps.find( step => { return customOptions.titleOptions.includes(step.get("description").get("title")) });
              if( matchingStep ){
                sortedCustomMethodSteps.push(matchingStep);
              }
            });
          }
        }
        catch(e){
          console.error("Could not sort the custom methods during serialization. Will proceed without sorting the custom method steps: ", e);
          sortedCustomMethodSteps = customMethodSteps;
        }

        //Update each method step and prepend to the top of the methods (reverse arrays first to keep the right order)
        regularMethodSteps.reverse().concat(sortedCustomMethodSteps.reverse()).forEach(step => {
          objectDOM.prepend(step.updateDOM());
        });
      }
      catch(e){
        console.error("Failed to serialize the method steps. Proceeding without updating. ", e);
      }

      try{
        // Update the sampling metadata
        if (this.get('samplingDescription') || this.get('studyExtentDescription')) {

          var samplingEl    = $(document.createElement('sampling')),
              studyExtentEl = $(document.createElement('studyExtent')),
              missingStudyExtent = false,
              missingDescription = false;

          //If there is a study extent description, then create a DOM element for it and append it to the parent node
          if (this.get('studyExtentDescription') && !this.get('studyExtentDescription').isEmpty()) {
            $(studyExtentEl).append(this.get('studyExtentDescription').updateDOM());

            //If the text matches the default "filler" text, then mark it as missing
            if( this.get('studyExtentDescription').get("text")[0] == "No study extent description provided."){
              missingStudyExtent = true;
            }

          }
          //If there isn't a study extent description, then mark it as missing and append the default "filler" text
          else {
            missingStudyExtent = true;
            $(studyExtentEl).append($(document.createElement('description')).html("<para>No study extent description provided.</para>"));
          }

          //Add the study extent element to the sampling element
          $(samplingEl).append(studyExtentEl);

          //If there is a sampling description, then create a DOM element for it and append it to the parent node
          if (this.get('samplingDescription') && !this.get('samplingDescription').isEmpty()) {
            $(samplingEl).append(this.get('samplingDescription').updateDOM());

            //If the text matches the default "filler" text, then mark it as missing
            if( this.get('samplingDescription').get("text")[0] == "No sampling description provided."){
              missingDescription = true;
            }

          }
          //If there isn't a study extent description, then mark it as missing and append the default "filler" text
          else {
            missingDescription = true;
            $(samplingEl).append($(document.createElement('samplingDescription')).html("<para>No sampling description provided.</para>"));
          }

          //Find the existing <sampling> element
          var existingSampling = objectDOM.find("sampling");

          //Remove all the sampling nodes if there is no study extent and no description
          if(missingStudyExtent && missingDescription){
            existingSampling.remove();
          }
          //Replace the existing sampling element, if it exists
          else if( existingSampling.length > 0 ){
            existingSampling.replaceWith(samplingEl);
          }
          //Or append a new one
          else{
            objectDOM.append(samplingEl);
          }
        }
      }
      catch(e){
        console.error("Error while serializing the study extent and sampling. Won't update. ", e);
      }


      // Remove empty (zero-length or whitespace-only) nodes
      objectDOM.find("*").filter(function() { return $.trim(this.innerHTML) === ""; } ).remove();

      //Check if all the content is filler content. This means there are no method steps, no sampling description, and
      // no study extent description.
      if( objectDOM.find("samplingdescription").length == 1 &&
          objectDOM.find("samplingdescription para").text() == "No sampling description provided." &&
          objectDOM.find("studyextent").length == 1 &&
          objectDOM.find("studyextent description para").text() == "No study extent description provided." ){

          //If it is all empty / filler content, then totally remove the methods
          return "";

      }

      //If there are sampling nodes listed before methodStep nodes, then reorder them
      if( objectDOM.children().index(objectDOM.find("methodstep").last()) >
            objectDOM.children().index(objectDOM.find("sampling").last()) ){

        //Detach all the sampling nodes and append them to the parent node
        objectDOM.append( objectDOM.children("sampling").detach() );

      }

      //If there are sampling nodes but no method nodes, make method nodes
      if( objectDOM.find("samplingdescription").length > 0 &&
          objectDOM.find("studyextent").length > 0){
        //Make a filler method node
        if(!objectDOM.find("methodstep").length){
            objectDOM.prepend("<methodstep><description><para>No method step description provided.</para></description></methodstep>");
        }
        else if(objectDOM.find("methodstep").length > 1){
          //If there is more than one method step, remove any that have the default filler text
          objectDOM.find("methodstep:contains('No method step description provided.')").remove();
          //Double check that there is always at least one method step (or there will be an EML validation error)
          if(!objectDOM.find("methodstep").length){
            objectDOM.prepend("<methodstep><description><para>No method step description provided.</para></description></methodstep>");
          }
        }

      }

       return objectDOM.length? objectDOM[0] : objectDOM;
    },

    /**
    * Creates a new EMLMethodStep model and adds it to this model
    * @param {object} [attr] A literal object of attributes to set on the EMLMethodStep
    * @since 2.19.0
    */
    addMethodStep: function(attr){

      try{

        if(!attr){
          let attr = {}
        }

        let newStep = new EMLMethodStep(attr);
        this.get("methodSteps").push(newStep);
        this.set("methodSteps", this.get("methodSteps"));
        return newStep;

      }
      catch(e){
        console.error(e);
      }

    },

    /**
    * Removes the given EMLMethodStep from the overall EMLMethods
    * @param {EMLMethodStep} step The EMLMethodStep to remove
    * @since 2.19.0
    */
    removeMethodStep: function(step){
      try{

        if( !step ) return;

        //Remove the EMLMethodStep from the steps list
        this.set("methodSteps", _.without(this.get("methodSteps"), step));

        //If this was the last step to be removed, and the rest of the EMLMethods
        // model is empty, then remove the model from the parent EML model
        if( this.isEmpty() ){
          //Get the parent EML model
          var parentEML = this.getParentEML();

          //Make sure this model type is EML211
          if( parentEML && parentEML.type == "EML" ){

            //If the methods are an array,
            if( Array.isArray(parentEML.get("methods")) ){
              //remove this EMLMethods model from the array
              parentEML.set( "methods", _.without(parentEML.get("methods"), this) );
            }
            else{
              //If the methods attribute is set to this EMLMethods model,
              // then just set it back to it's default
              if( parentEML.get("methods") == this )
                parentEML.set("methods", parentEML.defaults().methods);
            }
          }

        }

        this.trickleUpChange();

      }
      catch(e){
        console.error("Error while trying to remove a method step: ", e);
      }
    },

    /**
    * Returns the EMLMethodSteps that are not custom methods, as configured in {@link AppConfig#customEMLMethods}
    * @returns {EMLMethodStep[]}
    * @since 2.19.0
    */
    getNonCustomSteps: function(){
      return this.get("methodSteps").filter(step => !step.isCustom());
    },

    /**
    * Returns the EMLMethodSteps that are custom methods, as configured in {@link AppConfig#customEMLMethods}
    * @returns {EMLMethodStep[]}
    * @since 2.19.0
    */
    getCustomSteps: function(){
      return this.get("methodSteps").filter(step => step.isCustom());
    },

    /**
    *  function isEmpty() - Will check if there are any values set on this model
    * that are different than the default values and would be serialized to the EML.
    *
    * @return {boolean} - Returns true is this model is empty, false if not
    */
    isEmpty: function(){

      var methodsStepsEmpty = false,
          studyExtentEmpty = false,
          samplingEmpty = false;

      if( !this.get("methodSteps").length || !this.get("methodSteps") || this.get("methodSteps").every(step => step.isEmpty()) ){
        methodsStepsEmpty = true;
      }

      if( this.get("studyExtentDescription") == this.defaults().studyExtentDescription ||
          !this.get("studyExtentDescription") ||
          (this.get("studyExtentDescription").isEmpty && this.get("studyExtentDescription").isEmpty()) ||
          (Array.isArray(this.get("studyExtentDescription")) && !this.get("studyExtentDescription").length ) ||
          (Array.isArray(this.get("studyExtentDescription")) &&
           this.get("studyExtentDescription").length == 1 &&
           this.get("studyExtentDescription")[0].get("text").length == 1 &&
           this.get("studyExtentDescription")[0].get("text")[0] == "No study extent description provided.") ){

          studyExtentEmpty = true;

      }

      if( this.get("samplingDescription") == this.defaults().samplingDescription ||
          !this.get("samplingDescription") ||
          (this.get("samplingDescription").isEmpty && this.get("samplingDescription").isEmpty()) ||
          (Array.isArray(this.get("samplingDescription")) && !this.get("samplingDescription").length ) ||
          (Array.isArray(this.get("samplingDescription")) &&
           this.get("samplingDescription").length == 1 &&
           this.get("samplingDescription")[0].get("text").length == 1 &&
           this.get("samplingDescription")[0].get("text")[0] == "No sampling description provided.") ){

          samplingEmpty = true;

      }

      if( methodsStepsEmpty && studyExtentEmpty && samplingEmpty )
        return true;

    },

    /**
    * Overloads Backbone.Model.validate() to check if this model has valid values set on it.
    * For now, only the custom method steps are validated, because they could be required.
    * @extends Backbone.Model.validate
    * @returns {object}
    */
    validate: function(){

      try{

        let validationErrors = {}

        //Validate each custom Method Step
        let customSteps = this.getCustomSteps(),
            methodStepValidationErrors = {};

        customSteps.forEach(step => {
          if( !step.isValid() ){
           methodStepValidationErrors[step.get("customMethodID")] = step.validationError;
          }
        });

        if( Object.keys(methodStepValidationErrors).length ){
          validationErrors.methodSteps = methodStepValidationErrors;
        }

        //Check for the required fields
        let isRequired = MetacatUI.appModel.get("emlEditorRequiredFields").methods === true;
        if( isRequired ){
          let steps = this.getNonCustomSteps();
          if( !steps || !steps.length){
            validationErrors.methodSteps = "At least one method step is required.";
          }
        }

        return Object.keys(validationErrors).length? validationErrors : false;

      }
      catch(e){
        console.error("Error while validating the Methods: ", e);
        return false;
      }

    },

    /**
    * Climbs up the model heirarchy until it finds the EML model
    *
    * @return {EML211|false} - Returns the EML 211 Model or false if not found
    */
    getParentEML: function(){
      var emlModel = this.get("parentModel"),
          tries = 0;

      while (emlModel.type !== "EML" && tries < 6){
        emlModel = emlModel.get("parentModel");
        tries++;
      }

      if( emlModel && emlModel.type == "EML")
        return emlModel;
      else
        return false;

    },

    trickleUpChange: function(){
      MetacatUI.rootDataPackage.packageModel.set("changed", true);
    },

    formatXML: function(xmlString){
      return DataONEObject.prototype.formatXML.call(this, xmlString);
    },

    /**
    * Creates and returns the custom Method Step models, as configured in the {@link AppConfig}
    * @returns {EMLMethodStep[]}
    * @since 2.19.0
    */
    createCustomMethodSteps: function(){
      //Get the custom methods configured in the app
      let configCustomMethods = MetacatUI.appModel.get("customEMLMethods"),
          customMethods = [];

      //If there is at least one
      configCustomMethods.forEach(config => {
        customMethods.push(new EMLMethodStep({
          customMethodID: config.id,
          required: config.required
        }))
      });

      return customMethods;
    }
  });

  return EMLMethods;
});