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

define([
  "jquery",
  "underscore",
  "backbone",
  "uuid",
  "models/DataONEObject",
  "models/metadata/eml211/EMLAttribute",
], function ($, _, Backbone, uuid, DataONEObject, EMLAttribute) {
  /**
   * @class EMLEntity
   * @classdesc EMLEntity represents an abstract data entity, corresponding
   * with the EML EntityGroup and other elements common to all
   * entity types, including otherEntity, dataTable, spatialVector,
   * spatialRaster, and storedProcedure
   * @classcategory Models/Metadata/EML211
   * @see https://eml.ecoinformatics.org/schema/eml-entity_xsd
   * @extends Backbone.Model
   */
  var EMLEntity = Backbone.Model.extend(
    /** @lends EMLEntity.prototype */ {
      //The class name for this model
      type: "EMLEntity",

      /* Attributes of any entity */
      defaults: function () {
        return {
          /* Attributes from EML */
          xmlID: null, // The XML id of the entity
          alternateIdentifier: [], // Zero or more alt ids
          entityName: null, // Required, the name of the entity
          entityDescription: null, // Description of the entity
          physical: [], // Zero to many EMLPhysical objects
          physicalMD5Checksum: null,
          physicalSize: null,
          physicalObjectName: null,
          coverage: [], // Zero to many EML{Geo|Taxon|Temporal}Coverage objects
          methods: null, // Zero or one EMLMethod object
          additionalInfo: [], // Zero to many EMLText objects
          attributeList: [], // Zero to many EMLAttribute objects
          constraint: [], // Zero to many EMLConstraint objects
          references: null, // A reference to another EMLEntity by id (needs work)

          //Temporary attribute until we implement the eml-physical module
          downloadID: null,
          formatName: null,

          /* Attributes not from EML */
          nodeOrder: [
            // The order of the top level XML element nodes
            "alternateIdentifier",
            "entityName",
            "entityDescription",
            "physical",
            "coverage",
            "methods",
            "additionalInfo",
            "annotation",
            "attributeList",
            "constraint",
          ],
          parentModel: null, // The parent model this entity belongs to
          dataONEObject: null, //Reference to the DataONEObject this EMLEntity describes
          objectXML: null, // The serialized XML of this EML entity
          objectDOM: null, // The DOM of this EML entity
          type: "otherentity",
        };
      },

      /*
       * The map of lower case to camel case node names
       * needed to deal with parsing issues with $.parseHTML().
       * Use this until we can figure out issues with $.parseXML().
       */
      nodeNameMap: {
        alternateidentifier: "alternateIdentifier",
        entityname: "entityName",
        entitydescription: "entityDescription",
        additionalinfo: "additionalInfo",
        attributelist: "attributeList",
      },

      /* Initialize an EMLEntity object */
      initialize: function (attributes, options) {
        // if options.parse = true, Backbone will call parse()

        // Register change events
        this.on(
          "change:alternateIdentifier " +
            "change:entityName " +
            "change:entityDescription " +
            "change:physical " +
            "change:coverage " +
            "change:methods " +
            "change:additionalInfo " +
            "change:attributeList " +
            "change:constraint " +
            "change:references",
          EMLEntity.trickleUpChange,
        );

        //Listen to changes on the DataONEObject file name
        if (this.get("dataONEObject")) {
          this.listenTo(
            this.get("dataONEObject"),
            "change:fileName",
            this.updateFileName,
          );
        }

        //Listen to changes on the DataONEObject to reset the listener
        this.on("change:dataONEObject", function (entity, dataONEObj) {
          //Stop listening to the old DataONEObject
          if (this.previous("dataONEObject")) {
            this.stopListening(
              this.previous("dataONEObject"),
              "change:fileName",
            );
          }

          //Listen to changes on the file name
          this.listenTo(dataONEObj, "change:fileName", this.updateFileName);
        });
      },

      /*
       * Parse the incoming entity's common XML elements
       * Content example:
       * <otherEntity>
       *     <alternateIdentifier>file-alt.1.1.txt</alternateIdentifier>
       *     <alternateIdentifier>file-again.1.1.txt</alternateIdentifier>
       *     <entityName>file.1.1.txt</entityName>
       *     <entityDescription>A file summary</entityDescription>
       * </otherEntity>
       */
      parse: function (attributes, options) {
        var $objectDOM;
        var objectDOM = attributes.objectDOM;
        var objectXML = attributes.objectXML;

        // Use the cached object if we have it
        if (objectDOM) {
          $objectDOM = $(objectDOM);
        } else if (objectXML) {
          $objectDOM = $(objectXML);
        }

        // Add the XML id
        attributes.xmlID = $objectDOM.attr("id");

        // Add the alternateIdentifiers
        attributes.alternateIdentifier = [];
        var alternateIds = $objectDOM.children("alternateidentifier");
        _.each(alternateIds, function (alternateId) {
          attributes.alternateIdentifier.push(alternateId.textContent);
        });

        // Add the entityName
        attributes.entityName = $objectDOM.children("entityname").text();

        // Add the entityDescription
        attributes.entityDescription = $objectDOM
          .children("entitydescription")
          .text();

        //Get some physical attributes from the EMLPhysical module
        var physical = $objectDOM.find("physical");
        if (physical) {
          attributes.physicalSize = physical.find("size").text();
          attributes.physicalObjectName = physical.find("objectname").text();

          var checksumType = physical.find("authentication").attr("method");
          if (checksumType == "MD5")
            attributes.physicalMD5Checksum = physical
              .find("authentication")
              .text();
        }

        attributes.objectXML = objectXML;
        attributes.objectDOM = $objectDOM[0];

        //Find the id from the download distribution URL
        var urlNode = $objectDOM.find("url");
        if (urlNode.length) {
          var downloadURL = urlNode.text(),
            downloadID = "";

          if (downloadURL.indexOf("/resolve/") > -1)
            downloadID = downloadURL.substring(
              downloadURL.indexOf("/resolve/") + 9,
            );
          else if (downloadURL.indexOf("/object/") > -1)
            downloadID = downloadURL.substring(
              downloadURL.indexOf("/object/") + 8,
            );
          else if (downloadURL.indexOf("ecogrid") > -1) {
            var withoutEcoGridPrefix = downloadURL.substring(
                downloadURL.indexOf("ecogrid://") + 10,
              ),
              downloadID = withoutEcoGridPrefix.substring(
                withoutEcoGridPrefix.indexOf("/") + 1,
              );
          }

          if (downloadID.length) attributes.downloadID = downloadID;
        }

        //Find the format name
        var formatNode = $objectDOM.find("formatName");
        if (formatNode.length) {
          attributes.formatName = formatNode.text();
        }

        // Add the attributeList
        var attributeList = $objectDOM.find("attributelist");
        var attribute; // An individual EML attribute
        var options = { parse: true };
        attributes.attributeList = [];
        if (attributeList.length) {
          _.each(
            attributeList[0].children,
            function (attr) {
              attribute = new EMLAttribute(
                {
                  objectDOM: attr,
                  objectXML: attr.outerHTML,
                  parentModel: this,
                },
                options,
              );
              // Can't use this.addAttribute() here (no this yet)
              attributes.attributeList.push(attribute);
            },
            this,
          );
        }
        return attributes;
      },

      /*
       * Add an attribute to the attributeList, inserting it
       * at the zero-based index
       */
      addAttribute: function (attribute, index) {
        if (typeof index == "undefined") {
          this.get("attributeList").push(attribute);
        } else {
          this.get("attributeList").splice(index, attribute);
        }

        this.trigger("change:attributeList");
      },

      /*
       * Remove an EMLAttribute model from the attributeList array
       *
       * @param {EMLAttribute} - The EMLAttribute model to remove from this model's attributeList
       */
      removeAttribute: function (attribute) {
        //Get the index of the EMLAttribute in the array
        var attrIndex = this.get("attributeList").indexOf(attribute);

        //If this attribute model does not exist in the attribute list, don't do anything
        if (attrIndex == -1) {
          return;
        }

        //Remove that index from the array
        this.get("attributeList").splice(attrIndex, 1);

        //Trickle the change up the model chain
        this.trickleUpChange();
      },

      /* Validate the top level EMLEntity fields */
      validate: function () {
        var errors = {};

        // will be run by calls to isValid()
        if (!this.get("entityName")) {
          errors.entityName = "An entity name is required.";
        }

        //Validate the attributes
        var attributeErrors = this.validateAttributes();
        if (attributeErrors.length) errors.attributeList = attributeErrors;

        if (Object.keys(errors).length) return errors;
        else {
          this.trigger("valid");
          return false;
        }
      },

      /*
       * Validates each of the EMLAttribute models in the attributeList
       *
       * @return {Array} - Returns an array of error messages for all the EMlAttribute models
       */
      validateAttributes: function () {
        var errors = [];

        //Validate each of the EMLAttributes
        _.each(this.get("attributeList"), function (attribute) {
          if (!attribute.isValid()) {
            errors.push(attribute.validationError);
          }
        });

        return errors;
      },

      /* Copy the original XML and update fields in a DOM object */
      updateDOM: function (objectDOM) {
        var nodeToInsertAfter;
        var type = this.get("type") || "otherEntity";
        if (!objectDOM) {
          objectDOM = this.get("objectDOM");
        }
        var objectXML = this.get("objectXML");

        // If present, use the cached DOM
        if (objectDOM) {
          objectDOM = objectDOM.cloneNode(true);

          // otherwise, use the cached XML
        } else if (objectXML) {
          objectDOM = $(objectXML)[0].cloneNode(true);

          // This is new, create it
        } else {
          objectDOM = document.createElement(type);
        }

        //Update the id attribute on this XML node
        // update the id attribute
        if (this.get("dataONEObject")) {
          //Ideally, the EMLEntity will use the object's id in it's id attribute, so we wil switch them
          var xmlID = this.get("dataONEObject").getXMLSafeID();

          //Set the xml-safe id on the model and use it as the id attribute
          $(objectDOM).attr("id", xmlID);
          this.set("xmlID", xmlID);
        }
        //If there isn't a matching DataONEObject but there is an id set on this model, use that id
        else if (this.get("xmlID")) {
          $(objectDOM).attr("id", this.get("xmlID"));
        }

        // Update the alternateIdentifiers
        var altIDs = this.get("alternateIdentifier");
        if (altIDs) {
          if (altIDs.length) {
            // Copy and reverse the array for prepending
            altIDs = Array.from(altIDs).reverse();
            // Remove all current alternateIdentifiers
            $(objectDOM).find("alternateIdentifier").remove();
            // Add the new list back in
            _.each(altIDs, function (altID) {
              $(objectDOM).prepend(
                $(document.createElement("alternateIdentifier")).text(altID),
              );
            });
          }
        } else {
          // Remove all current alternateIdentifiers
          $(objectDOM).find("alternateIdentifier").remove();
        }

        // Update the entityName
        if (this.get("entityName")) {
          if ($(objectDOM).find("entityName").length) {
            $(objectDOM).find("entityName").text(this.get("entityName"));
          } else {
            nodeToInsertAfter = this.getEMLPosition(objectDOM, "entityName");
            if (!nodeToInsertAfter) {
              $(objectDOM).append(
                $(document.createElement("entityName")).text(
                  this.get("entityName"),
                )[0],
              );
            } else {
              $(nodeToInsertAfter).after(
                $(document.createElement("entityName")).text(
                  this.get("entityName"),
                )[0],
              );
            }
          }
        }

        // Update the entityDescription
        if (this.get("entityDescription")) {
          if ($(objectDOM).find("entityDescription").length) {
            $(objectDOM)
              .find("entityDescription")
              .text(this.get("entityDescription"));
          } else {
            nodeToInsertAfter = this.getEMLPosition(
              objectDOM,
              "entityDescription",
            );
            if (!nodeToInsertAfter) {
              $(objectDOM).append(
                $(document.createElement("entityDescription")).text(
                  this.get("entityDescription"),
                )[0],
              );
            } else {
              $(nodeToInsertAfter).after(
                $(document.createElement("entityDescription")).text(
                  this.get("entityDescription"),
                )[0],
              );
            }
          }
        }
        //If there is no entity description
        else {
          //If there is an entity description node in the XML, remove it
          $(objectDOM).find("entityDescription").remove();
        }

        // TODO: Update the physical section

        // TODO: Update the coverage section

        // TODO: Update the methods section

        // Update the additionalInfo
        var addInfos = this.get("additionalInfo");
        if (addInfos) {
          if (addInfos.length) {
            // Copy and reverse the array for prepending
            addInfos = Array.from(addInfos).reverse();
            // Remove all current alternateIdentifiers
            $(objectDOM).find("additionalInfo").remove();
            // Add the new list back in
            _.each(addInfos, function (additionalInfo) {
              $(objectDOM).prepend(
                document.createElement("additionalInfo").text(additionalInfo),
              );
            });
          }
        }

        // Update the attributeList section
        let attributeList = this.get("attributeList");
        let attributeListInDOM = $(objectDOM).children("attributelist");
        let attributeListNode;
        if (attributeListInDOM.length) {
          attributeListNode = attributeListInDOM[0];
          $(attributeListNode).children().remove(); // Each attr will be replaced
        } else {
          attributeListNode = document.createElement("attributeList");
          nodeToInsertAfter = this.getEMLPosition(objectDOM, "attributeList");
          if (!nodeToInsertAfter) {
            $(objectDOM).append(attributeListNode);
          } else {
            $(nodeToInsertAfter).after(attributeListNode);
          }
        }

        var updatedAttrDOM;
        if (attributeList.length) {
          // Add each attribute
          _.each(
            attributeList,
            function (attribute) {
              updatedAttrDOM = attribute.updateDOM();
              $(attributeListNode).append(updatedAttrDOM);
            },
            this,
          );
        } else {
          // Attributes are not defined, remove them from the DOM
          attributeListNode.remove();
        }

        // TODO: Update the constraint section

        return objectDOM;
      },

      /**
       * Update the file name in the EML
       */
      updateFileName: function () {
        var dataONEObj = this.get("dataONEObject");

        //Get the DataONEObject model associated with this EML Entity
        if (dataONEObj) {
          //If the last file name matched the EML entity name, then update it
          if (dataONEObj.previous("fileName") == this.get("entityName")) {
            this.set("entityName", dataONEObj.get("fileName"));
          }
          //If the DataONEObject doesn't have an old file name or entity name, then update it
          else if (
            !dataONEObj.previous("fileName") ||
            !this.get("entityName")
          ) {
            this.set("entityName", dataONEObj.get("fileName"));
          }
        }
      },

      /*
       * Get the DOM node preceding the given nodeName
       * to find what position in the EML document
       * the named node should be appended
       */
      getEMLPosition: function (objectDOM, nodeName) {
        var nodeOrder = this.get("nodeOrder");

        var position = _.indexOf(nodeOrder, nodeName);

        // Append to the bottom if not found
        if (position == -1) {
          return $(objectDOM).children().last()[0];
        }

        // Otherwise, go through each node in the node list and find the
        // position where this node will be inserted after
        for (var i = position - 1; i >= 0; i--) {
          if ($(objectDOM).find(nodeOrder[i].toLowerCase()).length) {
            return $(objectDOM).find(nodeOrder[i].toLowerCase()).last()[0];
          }
        }
      },

      /*
       * Climbs up the model heirarchy until it finds the EML model
       *
       * @return {EML211 or 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;
      },

      /*Format the EML XML for entities*/
      formatXML: function (xmlString) {
        return DataONEObject.prototype.formatXML.call(this, xmlString);
      },

      /* Let the top level package know of attribute changes from this object */
      trickleUpChange: function () {
        MetacatUI.rootDataPackage.packageModel.set("changed", true);
      },
    },
  );

  return EMLEntity;
});