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

define([
  "jquery",
  "underscore",
  "backbone",
  "models/DataONEObject",
  "models/metadata/eml211/EMLAttribute",
  "collections/metadata/eml/EMLAttributes",
], ($, _, Backbone, DataONEObject, EMLAttribute, EMLAttributes) => {
  /**
   * @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
   * @augments Backbone.Model
   */
  const EMLEntity = Backbone.Model.extend(
    /** @lends EMLEntity.prototype */ {
      // The class name for this model
      type: "EMLEntity",

      /**
       * Attributes of any entity
       * @returns {object} - The default attributes
       * @property {string} xmlID - The XML id of the entity
       * @property {Array} alternateIdentifier - Zero or more alt ids
       * @property {string} entityName - Required, the name of the entity
       * @property {string} entityDescription - Description of the entity
       * @property {Array} physical - Zero to many EMLPhysical objects
       * @property {string} physicalMD5Checksum - The MD5 checksum of the
       * physical object
       * @property {string} physicalSize - The size of the physical object in
       * bytes
       * @property {string} physicalObjectName - The name of the physical object
       * @property {Array} coverage - Zero to many
       * EML{Geo|Taxon|Temporal}Coverage objects
       * @property {EMLMethod} methods - Zero or one EMLMethod object
       * @property {Array} additionalInfo - Zero to many EMLText objects
       * @property {EMLAttributes} attributeList - Zero to many EMLAttribute
       * objects as a collection
       * @property {Array} constraint - Zero to many EMLConstraint objects
       * @property {string} references - A reference to another EMLEntity by id
       * (needs work)
       * @property {string} downloadID - A temporary attribute until we
       * implement the eml-physical module
       * @property {string} formatName - A temporary attribute until we
       * implement the eml-physical module
       * @property {Array} nodeOrder - The order of the top level XML element
       * nodes
       * @property {EML211} parentModel - The parent model this entity belongs
       * to
       * @property {DataONEObject} dataONEObject - Reference to the
       * DataONEObject this EMLEntity describes
       * @property {string} objectXML - The serialized XML of this EML entity
       * @property {object} objectDOM - The DOM of this EML entity
       * @property {string} type - The type of entity
       */
      defaults() {
        return {
          // Attributes from EML
          xmlID: null,
          alternateIdentifier: [],
          entityName: null,
          entityDescription: null,
          physical: [],
          physicalMD5Checksum: null,
          physicalSize: null,
          physicalObjectName: null,
          coverage: [],
          methods: null,
          additionalInfo: [],
          attributeList: new EMLAttributes(),
          constraint: [],
          references: null,

          // 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,
          dataONEObject: null,
          objectXML: null,
          objectDOM: null,
          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",
      },

      /** @inheritdoc */
      initialize(_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: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
        const model = this;
        this.on("change:dataONEObject", (_entity, dataONEObj) => {
          // Stop listening to the old DataONEObject
          if (model.previous("dataONEObject")) {
            model.stopListening(
              model.previous("dataONEObject"),
              "change:fileName",
            );
          }
          // Listen to changes on the file name
          model.listenTo(dataONEObj, "change:fileName", model.updateFileName);
        });

        this.listenToAttributeList();
      },

      /**
       * Listen to changes on the attributeList collection and trigger change
       * events on the parent model and collection
       */
      listenToAttributeList() {
        const attrList = this.get("attributeList");
        this.stopListening(attrList, "update change");
        this.listenTo(attrList, "update change", () => {
          this.trickleUpChange();
          this.trigger("change", this, this.get("attributeList"));
          this.collection.trigger("update", this, this.get("attributeList"));
        });
        this.listenTo(this, "change:attributeList", () => {
          const previousList = this.previous("attributeList");
          if (previousList) {
            this.stopListening(previousList, "update change");
          }
          this.listenToAttributeList();
        });
      },

      /**
       * 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>
       * @param {object} attrs - The XML attributes
       * @param {object} _options - Any options passed to the parse function
       * @returns {object} - The parsed attributes
       */
      parse(attrs, _options) {
        const attributes = attrs || {};
        let $objectDOM;
        const { objectDOM } = attributes;
        const { objectXML } = attributes;

        // 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 = [];
        const alternateIds = $objectDOM.children("alternateidentifier");
        _.each(alternateIds, (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
        const physical = $objectDOM.find("physical");
        if (physical) {
          attributes.physicalSize = physical.find("size").text();
          attributes.physicalObjectName = physical.find("objectname").text();

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

        attributes.objectXML = objectXML;
        [attributes.objectDOM] = Array.from($objectDOM);

        // Find the id from the download distribution URL
        const urlNode = $objectDOM.find("url");
        if (urlNode.length) {
          const downloadURL = urlNode.text();
          let 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) {
            const withoutEcoGridPrefix = downloadURL.substring(
              downloadURL.indexOf("ecogrid://") + 10,
            );
            downloadID = withoutEcoGridPrefix.substring(
              withoutEcoGridPrefix.indexOf("/") + 1,
            );
          }

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

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

        // Attribute list expects a DOM node not jQuery object
        const attributeList = $objectDOM.find("attributelist").get(0);
        if (attributeList) {
          attributes.attributeList = new EMLAttributes(attributeList, {
            parse: true,
            parentModel: this,
          });
        }

        return attributes;
      },

      /**
       * Add an attribute to the attributeList, inserting it at the zero-based
       * index
       * @param {EMLAttribute} attribute - The EMLAttribute model to add
       * @param {number} index - The index to insert the attribute at
       */
      addAttribute(attribute, index) {
        const options = !index && index !== 0 ? {} : { at: index };
        attribute.set("parentModel", this);
        this.get("attributeList").add(attribute, options);
      },

      /**
       * Remove an EMLAttribute model from the attributeList array
       * @param {EMLAttribute} attribute - The EMLAttribute model to remove from
       * this model's attributeList
       */
      removeAttribute(attribute) {
        // Remove that index from the array
        this.get("attributeList").remove(attribute);
      },

      /** @inheritdoc */
      validate() {
        const errors = {};

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

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

        if (Object.keys(errors).length) return errors;

        this.trigger("valid");
        return false;
      },

      /**
       * Validates each of the EMLAttribute models in the attributeList
       * @returns {Array} - Returns an array of error messages for all the
       * EMlAttribute models
       */
      validateAttributes() {
        return this.get("attributeList").validate();
      },

      /**
       * Copy the original XML and update fields in a DOM object
       * @param {object} objectDOMOriginal - The original DOM object
       * @returns {object} - The updated DOM object
       */
      updateDOM(objectDOMOriginal) {
        // Copy the original DOM object
        let objectDOM = objectDOMOriginal?.cloneNode(true);
        let nodeToInsertAfter;
        const type = this.get("type") || "otherEntity";
        if (!objectDOM) {
          objectDOM = this.get("objectDOM");
        }
        const 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
          const 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
        let 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, (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
        let 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, (additionalInfo) => {
              $(objectDOM).prepend(
                document.createElement("additionalInfo").text(additionalInfo),
              );
            });
          }
        }

        // Update the attributeList section
        const attrListCollection = this.get("attributeList");
        const $attrList = $(objectDOM).find("attributelist");

        // If the attributeList is empty, remove it from the DOM
        if (
          !attrListCollection?.length ||
          !attrListCollection?.hasNonEmptyAttributes()
        ) {
          $attrList.remove();
        } else {
          // Attribute list coll expects a DOM node not jQuery object
          const newAttrListDOM = attrListCollection.updateDOM($attrList.get(0));

          // If there wasn't already an attributeList in the DOM, add the new
          // one created by the collection
          if (!$attrList.length) {
            nodeToInsertAfter = this.getEMLPosition(objectDOM, "attributeList");
            if (!nodeToInsertAfter) {
              $(objectDOM).append(newAttrListDOM);
            } else {
              $(nodeToInsertAfter).after(newAttrListDOM);
            }
          } else {
            // Otherwise, update the existing attributeList
            $attrList.replaceWith(newAttrListDOM);
          }
        }

        return objectDOM;
      },

      /**
       * Update the file name in the EML
       */
      updateFileName() {
        const 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 file name for the entity
       * @returns {string} - The file name for the entity
       */
      getFileName() {
        return (
          this.get("entityName") ||
          this.get("physicalObjectName") ||
          this.get("dataONEObject").get("fileName")
        );
      },

      /**
       * Get the id for the entity
       * @returns {string} - The id for the entity
       */
      getId() {
        return this.get("xmlID") || this.get("dataONEObject")?.get("id");
      },

      /**
       * Get the DOM node preceding the given nodeName to find what position in
       * the EML document the named node should be appended
       * @param {object} objectDOM - The DOM object to search
       * @param {string} nodeName - The name of the node to find
       * @returns {object} - The DOM node to insert after
       */
      getEMLPosition(objectDOM, nodeName) {
        const nodeOrder = this.get("nodeOrder");

        const 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 (let i = position - 1; i >= 0; i -= 1) {
          if ($(objectDOM).find(nodeOrder[i].toLowerCase()).length) {
            return $(objectDOM).find(nodeOrder[i].toLowerCase()).last()[0];
          }
        }

        // If no nodes are found, append to the bottom
        return $(objectDOM).children().last()[0];
      },

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

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

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

      /**
       * Format the EML XML for entities
       * @param {string} xmlString - The XML string to format
       * @returns {string} - The formatted XML string
       */
      formatXML(xmlString) {
        return DataONEObject.prototype.formatXML.call(this, xmlString);
      },

      /** Pass any change events up to the parent EML model */
      trickleUpChange() {
        MetacatUI.rootDataPackage?.packageModel?.set("changed", true);
      },
    },
  );

  return EMLEntity;
});