Source: src/js/models/sysmeta/SysMeta.js

define([], () => {
  // TODO: update eslint config to ecmaVersion 2022 to support static class
  // fields, then move these constants to the class body

  const SIMPLE_TEXT_FIELDS = [
    "identifier",
    "formatId",
    "submitter",
    "rightsHolder",
    "obsoletes",
    "obsoletedBy",
    "originMemberNode",
    "authoritativeMemberNode",
    "fileName",
  ];

  const SIMPLE_NUMBER_FIELDS = ["serialVersion", "size"];
  const SIMPLE_BOOLEAN_FIELDS = ["archived"];
  const DATE_FIELDS = ["dateUploaded", "dateSysMetadataModified"];
  const DEFAULTS = {
    identifier: null,
    formatId: null,
    size: null,
    checksum: null,
    checksumAlgorithm: null,
    submitter: null,
    rightsHolder: null,
    dateUploaded: null,
    dateSysMetadataModified: null,
    originMemberNode: null,
    authoritativeMemberNode: null,
    accessPolicy: [],
    replicationAllowed: false,
    numberReplicas: 0,
    preferredNodes: [],
    blockedNodes: [],
    obsoletes: null,
    obsoletedBy: null,
    archived: false,
    serialVersion: null,
  };

  const DEFAULT_META_SERVICE_URL = MetacatUI.appModel.get("metaServiceUrl");
  // TODO: Add more fields like accessPolicy, replicationPolicy, etc.
  // TODO: Add node order constant for serialization

  /**
   * Class representing System Metadata for a DataONE object. This class
   * currently only provides a basic implementation for fetching and parsing
   * system metadata from a DataONE service. It excludes parsing complex
   * elements like accessPolicy and replicationPolicy. In the future, all fields
   * will be implemented, and the class will support serialization to XML and
   * updating system metadata on the server.
   * @property {string} metaServiceUrl - The URL of the metadata service.
   * @property {object} data - The object that contains all the system metadata
   * fields, like identifier, formatId, size, checksum, etc.
   * @property {boolean} fetched - Indicates whether the system metadata has
   * been fetched successfully.
   * @property {boolean} fetchedWithError - Indicates whether there was an error
   * during the fetch operation.
   * @property {Array} errors - An array to hold any errors that occur during
   * the fetch operation.
   * @property {boolean} parsed - Indicates whether the system metadata has been
   * parsed from XML.
   * @property {string} url - The URL to fetch the system metadata.
   * @class SystemMetadata
   * @since 2.34.0
   */
  class SystemMetadata {
    /**
     * Creates an instance of SystemMetadata.
     * @class
     * @param {object} options - Configuration options for the SystemMetadata instance.
     * @param {string} options.identifier - The identifier for the DataONE object.
     * @param {string} [options.metaServiceUrl] - The URL of the metadata service.
     */
    constructor({ identifier, metaServiceUrl } = {}) {
      const defaultUrl = SystemMetadata.DEFAULT_META_SERVICE_URL;
      let url = metaServiceUrl || defaultUrl;
      url = typeof url !== "string" ? defaultUrl : url;

      if (!identifier) {
        throw new Error("identifier is required");
      }

      this.metaServiceUrl = url.endsWith("/") ? url : `${url}/`;

      // Initialize fields with null/defaults
      this.data = { ...DEFAULTS, identifier };

      // Initialize state, errors, and version tracking
      this.fetched = false;
      this.fetchedWithError = false;
    }

    /**
     * Returns the URL for fetching the system metadata.
     * @returns {string} The URL to fetch the system metadata.
     */
    get url() {
      const id = this.data.identifier;
      if (!id || typeof id !== "string") {
        throw new Error("identifier must be a non-empty string");
      }
      return `${this.metaServiceUrl}${encodeURIComponent(id)}`;
    }

    /**
     * Fetches the system metadata from the configured URL.
     * @param {string} [token] - Optional authentication token for the request.
     * @returns {Promise<object|null>} A promise that resolves to the system
     * metadata object or null if an error occurs.
     */
    async fetch(token) {
      this.parsed = false;
      this.fetched = false;
      this.fetchedWithError = false;

      const headers = token ? { Authorization: `Bearer ${token}` } : {};
      const options = { headers, credentials: "include" };

      let response;
      try {
        response = await fetch(this.url, options);
      } catch (networkError) {
        this.handleFetchError(networkError);
        return null;
      }

      const text = await response.text();

      if (!response.ok) {
        const parsedError = SystemMetadata.parseError(text);
        this.handleFetchError({
          status: parsedError?.status ?? response.status,
          message: parsedError?.message ?? "Failed to fetch system metadata",
        });
        return null;
      }

      try {
        const parsed = this.parse(text);
        this.data = { ...DEFAULTS, ...parsed };
        this.fetched = true;
        return this.data;
      } catch (parseError) {
        this.handleFetchError(parseError);
        return null;
      }
    }

    /**
     * Attempts to parse an xml error object returned from DataONE, e.g.:
     * <?xml version="1.0" encoding="UTF-8"?><error detailCode="1040" errorCode="401" name="NotAuthorized">
     * <description>READ not allowed on urn:uuid:c6556d90-4f58-4439-a309-a517a4fe3dc3 for subject[s]: public; </description>
     * </error>
     * @param {string} text - The XML string to parse.
     * @returns {Error|null} Returns a SysMetaError with the error message and status
     * if the XML contains an error element, or null if no error is found.
     */
    static parseError(text) {
      if (!text) return null;

      const parser = new DOMParser();
      const xmlDoc = parser.parseFromString(text, "application/xml");
      const errorEl = xmlDoc.querySelector("error");

      if (!errorEl) return null;

      const message = errorEl.querySelector("description")
        ? errorEl.querySelector("description").textContent.trim()
        : "Unknown error";
      const status = errorEl.getAttribute("errorCode") || "unknown";

      // return new SysMetaError(message, status);
      const error = new Error(message);
      error.name = "SysMetaError";
      error.status = status;
      return error;
    }

    /**
     * Handles errors that occur during the fetch operation.
     * @param {Error} e - The error object containing error details.
     */
    handleFetchError(e) {
      this.fetched = false;
      this.parsed = false;
      this.fetchedWithError = true;
      let status = e.status ?? e?.response?.status ?? 500;
      // try to coerce status to a number
      if (typeof status === "string") {
        status = parseInt(status, 10);
      }

      e.status = status;
      e.identifier = this.data.identifier;

      throw e;
    }

    /**
     * Parses the XML string into a system metadata object.
     * @param {string} xmlString - The XML string to parse.
     * @returns {object} The parsed system metadata object.
     */
    parse(xmlString) {
      this.parsed = false;
      this.fetchedXmlString = xmlString;
      const parser = new DOMParser();
      const xmlDoc = parser.parseFromString(xmlString, "application/xml");
      // Detect XML parser errors (e.g., when the server returns HTML)
      if (xmlDoc.querySelector("parsererror")) {
        throw new Error("Invalid SystemMetadata XML");
      }
      const sysMeta = {};

      const getText = (tag) => {
        const el = xmlDoc.querySelector(tag);
        return el ? el.textContent.trim() : null;
      };

      // Simple fields
      SIMPLE_TEXT_FIELDS.forEach((field) => {
        sysMeta[field] = getText(field);
      });

      SIMPLE_NUMBER_FIELDS.forEach((field) => {
        const value = getText(field);
        if (value !== null) sysMeta[field] = parseInt(value, 10);
      });

      SIMPLE_BOOLEAN_FIELDS.forEach((field) => {
        const value = getText(field);
        if (value !== null) sysMeta[field] = value.toLowerCase() === "true";
      });

      DATE_FIELDS.forEach((field) => {
        const value = getText(field);
        if (value !== null) sysMeta[field] = new Date(value);
      });

      const checksumEl = xmlDoc.querySelector("checksum");
      if (checksumEl) {
        sysMeta.checksum = checksumEl.textContent.trim();
        sysMeta.checksumAlgorithm = checksumEl.getAttribute("algorithm");
      }

      // TODO: accessPolicy, replicationPolicy, etc.
      this.parsed = true;
      return sysMeta;
    }
  }

  SystemMetadata.DEFAULT_META_SERVICE_URL = DEFAULT_META_SERVICE_URL;

  return SystemMetadata;
});