Source: src/js/common/XMLUtilities.js

"use strict";

define([], () => {
  // Utility function to check if a value is a string
  const isString = (value) =>
    typeof value === "string" || value instanceof String;

  /**
   * Error raised when XML input cannot be parsed.
   * @class ParseError
   * @augments Error
   * @memberof XMLUtilities
   * @classcategory Common
   * @since 2.39.0
   */
  const ParseError = class extends Error {
    /**
     * Create an XML parse error.
     * @param {string} message Error message
     */
    constructor(message) {
      super(message);
      this.name = "ParseError";
    }
  };

  /**
   * @namespace XMLUtilities
   * @description A generic utility object that contains functions used
   * throughout MetacatUI to perform useful functions related to XML, but not
   * used to store or manipulate any state about the application.
   * @type {object}
   * @since 2.33.0
   */
  const XMLUtilities = /** @lends XMLUtilities.prototype */ {
    /**
     * Checks if a Unicode code point is valid according to the XML 1.0
     * specification. See https://www.w3.org/TR/xml/#charsets for the valid
     * character ranges.
     * @param {number} codePoint The Unicode code point to check
     * @returns {boolean} True if the code point is valid in XML, false
     * otherwise
     * @since 2.39.0
     */
    isValidXmlCodePoint(codePoint) {
      return (
        codePoint === 0x9 ||
        codePoint === 0xa ||
        codePoint === 0xd ||
        (codePoint >= 0x20 && codePoint <= 0xd7ff) ||
        (codePoint >= 0xe000 && codePoint <= 0xfffd) ||
        (codePoint >= 0x10000 && codePoint <= 0x10ffff)
      );
    },

    /**
     * Remove XML 1.0-invalid characters from text intended for XML text nodes.
     * This helper does not escape `&`, `<`, or other XML syntax characters;
     * those are handled by XMLSerializer during DOM serialization. See
     * https://www.w3.org/TR/xml/#charsets
     * @param {*} textString Value to normalize. Non-null values are coerced to
     * a string before invalid XML characters are removed.
     * @returns {string|null} The normalized string, or null for nullish values.
     * @since 2.39.0
     */
    removeInvalidXmlCharacters(textString) {
      if (textString === undefined || textString === null) {
        return null;
      }

      return Array.from(String(textString))
        .filter((character) =>
          this.isValidXmlCodePoint(character.codePointAt(0)),
        )
        .join("");
    },

    /**
     * Convert an identifier to the legacy XML-safe ID form used by rendered
     * metadata annotations.
     * @param {*} id Identifier.
     * @returns {string|null} XML-safe ID, or null for empty input.
     * @since 2.39.0
     */
    getXMLSafeID(id) {
      if (id === undefined || id === null) return null;
      const normalized = String(id).trim();
      if (!normalized) return null;
      return normalized
        .replace(/</g, "-")
        .replace(/:/g, "-")
        .replace(/&[a-zA-Z0-9]+;/g, "");
    },

    /**
     * Parse an XML string into a Document.
     * @param {string} xmlString XML text to parse.
     * @param {string} [context] Context label for parse errors.
     * @returns {Document|null} Parsed XML Document, or null for empty,
     * whitespace-only, or nullish input.
     * @throws {ParseError} When input is not a string or cannot be parsed
     * @since 2.39.0
     */
    parseXmlString(xmlString, context = "XML response") {
      if (xmlString === null || xmlString === undefined) {
        return null;
      }
      if (!isString(xmlString)) {
        throw new ParseError(
          `${context}: expected a string but got ${typeof xmlString}`,
        );
      }
      let doc = null;
      const trimmed = String(xmlString).trim();
      if (!trimmed) {
        return null;
      }
      try {
        doc = new DOMParser().parseFromString(trimmed, "application/xml");
      } catch (_error) {
        const erMessage =
          _error instanceof Error ? _error.message : String(_error);
        throw new ParseError(
          `${context}: failed to parse XML response, DOMParser threw an error: ${erMessage}`,
        );
      }

      const parserError = doc?.querySelector?.("parsererror");
      if (parserError) {
        const detail = parserError.textContent
          ? parserError.textContent.trim()
          : "Unknown parser error";
        throw new ParseError(
          `${context}: failed to parse XML response (DOMParser error: ${detail})`,
        );
      }

      return doc;
    },

    /**
     * Parse an XML string and require a non-empty XML document.
     * @param {string} xmlString XML text to parse.
     * @param {string} [context] Context label for parse errors.
     * @returns {Document} Parsed XML document.
     * @throws {ParseError} Throws when the XML is empty or invalid.
     * @since 2.39.0
     */
    parseRequiredXmlString(xmlString, context = "XML response") {
      const xml = this.parseXmlString(xmlString, context);
      if (!xml) {
        throw new ParseError(
          `${context}: response body must be non-empty XML text`,
        );
      }
      return xml;
    },

    /**
     * Return a lower-cased local element name with any namespace prefix
     * removed.
     * @param {Node} node The XML node to inspect.
     * @returns {string} The normalized local name, or an empty string.
     * @since 2.39.0
     */
    getNormalizedElementName(node) {
      const name = node?.localName || node?.nodeName || "";
      return name.split(":").pop().toLowerCase();
    },

    /**
     * Parses a simple CSS-like selector string into an array of selector parts,
     * each with a combinator and a tag name. Supports descendant combinators
     * (space) and child combinators (>), as well as optional namespace prefixes
     * in tag names.
     * @param {string} selector The simple selector string to parse (e.g.
     * "d1:identifier", "parent > child")
     * @returns {Array<{combinator: string, name: string}>|null} An array of
     * selector parts with combinators and tag names, or null if the selector is
     * invalid, e.g. [{ combinator: "descendant", name: "identifier" }] for
     * "d1:identifier", or [{ combinator: "descendant", name: "parent" }, {
     * combinator: "child", name: "child" }] for "parent > child"
     * @since 2.39.0
     */
    parseSimpleElementSelector(selector) {
      if (!isString(selector)) return null;

      const tokens = String(selector)
        .trim()
        .match(/(>|\S+)/g);
      if (!tokens?.length) return null;

      const result = tokens.reduce(
        (state, token) => {
          if (!state.valid) return state;

          if (token === ">") {
            return {
              ...state,
              combinator: "child",
            };
          }

          const normalizedToken = token.replace(/\\:/g, ":");
          if (!/^([A-Za-z_][\w.-]*:)?[A-Za-z_][\w.-]*$/.test(normalizedToken)) {
            return {
              ...state,
              valid: false,
            };
          }

          return {
            valid: true,
            combinator: "descendant",
            parts: [
              ...state.parts,
              {
                combinator: state.combinator,
                name: normalizedToken.split(":").pop().toLowerCase(),
              },
            ],
          };
        },
        {
          valid: true,
          combinator: "descendant",
          parts: [],
        },
      );

      return result.valid && result.parts.length ? result.parts : null;
    },

    /**
     * Find elements matching a simple selector, ignoring namespace prefixes in
     * element names. Used as a fallback when querySelector cannot handle XML
     * names safely.
     * @param {Document|Element} root Parsed XML document or element to search.
     * @param {string} selector Simple selector such as `d1:identifier` or
     * `parent > child`.
     * @returns {Element[]} Matching elements, or an empty array.
     * @since 2.39.0
     */
    findElementsBySimpleSelector(root, selector) {
      const parts = this.parseSimpleElementSelector(selector);
      if (!parts) return [];

      let currentNodes = root?.documentElement
        ? [root.documentElement]
        : [root];

      parts.forEach((part, partIndex) => {
        const isFirstPart = partIndex === 0;

        currentNodes = currentNodes.flatMap((node) => {
          if (!node) return [];

          let candidates = [];
          if (isFirstPart) {
            candidates = [
              ...(node.nodeType === 1 ? [node] : []),
              ...Array.from(node.getElementsByTagName?.("*") || []),
            ];
          } else if (part.combinator === "child") {
            candidates = Array.from(node?.children || []);
          } else {
            candidates = Array.from(node.getElementsByTagName?.("*") || []);
          }

          return candidates.filter(
            (candidate) =>
              this.getNormalizedElementName(candidate) === part.name,
          );
        });
      });

      return currentNodes;
    },

    /**
     * Find the first direct child element with the given local name. Matching
     * is case-insensitive and ignores namespace prefixes.
     * @param {Node} node The parent XML node.
     * @param {string} name Local child element name to match.
     * @returns {Element|null} The first matching child element, or null.
     * @since 2.39.0
     */
    findDirectChildElement(node, name) {
      const nodes = this.findDirectChildElements(node, name);
      return nodes.length ? nodes[0] : null;
    },

    /**
     * Find the last direct child element with the given local name. Matching is
     * case-insensitive and ignores namespace prefixes.
     * @param {Node} node The parent XML node.
     * @param {string} name Local child element name to match.
     * @returns {Element|null} The last matching child element, or null.
     * @since 2.39.0
     */
    findLastDirectChildElement(node, name) {
      const nodes = this.findDirectChildElements(node, name);
      return nodes.length ? nodes[nodes.length - 1] : null;
    },

    /**
     * Find all direct child elements with the given local name. Matching is
     * case-insensitive and ignores namespace prefixes.
     * @param {Node} node The parent XML node.
     * @param {string} name Local child element name to match.
     * @returns {Element[]} Matching child elements.
     * @since 2.39.0
     */
    findDirectChildElements(node, name) {
      const normalized = String(name || "").toLowerCase();
      return Array.from(node?.children || []).filter(
        (child) => this.getNormalizedElementName(child) === normalized,
      );
    },

    /**
     * Find the first element with the given local name anywhere under a parsed
     * XML document or node. Matching ignores namespace prefixes.
     * @param {Document|Node} documentOrNode Parsed XML document or node.
     * @param {string} name Local element name to match.
     * @returns {Element|null} The first matching element, or null.
     * @since 2.39.0
     */
    findFirstElement(documentOrNode, name) {
      const normalized = String(name || "").toLowerCase();
      if (
        documentOrNode?.documentElement &&
        this.getNormalizedElementName(documentOrNode.documentElement) ===
          normalized
      ) {
        return documentOrNode.documentElement;
      }
      // if its a plain Element, check it first before searching its children
      if (
        documentOrNode?.nodeType === 1 &&
        this.getNormalizedElementName(documentOrNode) === normalized
      ) {
        return documentOrNode;
      }

      return (
        Array.from(documentOrNode?.getElementsByTagName?.("*") || []).find(
          (element) => this.getNormalizedElementName(element) === normalized,
        ) || null
      );
    },

    /**
     * Extract trimmed text from the first matching direct child element.
     * @param {Node} node Parent XML node
     * @param {string} name Local child element name to match
     * @returns {string|null} Matching child text, or null
     * @since 2.39.0
     */
    getDirectChildText(node, name) {
      const element = this.findDirectChildElement(node, name);
      return element?.textContent?.trim() || null;
    },

    /**
     * Extract trimmed text from all matching direct child elements.
     * @param {Node} node The parent XML node.
     * @param {string} name Local child element name to match.
     * @returns {Array<string|null>} Matching child text values.
     * @since 2.39.0
     */
    getDirectChildTexts(node, name) {
      return this.findDirectChildElements(node, name).map(
        (child) => child?.textContent?.trim() || null,
      );
    },

    /**
     * Require that an element uses one of the expected namespace URIs.
     * @param {Element} element XML element to inspect.
     * @param {string[]} allowedNamespaceUris Allowed namespace URIs.
     * @param {string} [context] Context label for errors.
     * @returns {string} The matched namespace URI.
     * @throws {Error} Throws when the namespace URI is missing or unsupported.
     * @since 2.39.0
     */
    requireNamespaceUri(
      element,
      allowedNamespaceUris,
      context = "XML response",
    ) {
      const namespaceUri = element?.namespaceURI || null;
      const allowed = Array.isArray(allowedNamespaceUris)
        ? allowedNamespaceUris.filter(Boolean)
        : [];
      const elementLabel = element?.tagName || element?.nodeName || "element";

      if (allowed.includes(namespaceUri)) return namespaceUri;

      throw new Error(
        `${context}: <${elementLabel}> must use one of the supported namespace URIs: ${allowed.join(
          ", ",
        )}`,
      );
    },

    /**
     * Require that an element contains only allowed attribute names. Namespace
     * declaration attributes are ignored by default.
     * @param {Element} element XML element to inspect.
     * @param {string[]} allowedNames Allowed attribute names.
     * @param {string} [context] Context label for errors.
     * @param {object} [options] Additional options.
     * @param {boolean} [options.allowNamespaceDeclarations] Whether xmlns
     * attributes are ignored during validation.
     * @returns {string[]} Present non-namespace attribute names.
     * @throws {Error} Throws when an unexpected attribute is present.
     * @since 2.39.0
     */
    requireAllowedAttributeNames(
      element,
      allowedNames,
      context = "XML response",
      { allowNamespaceDeclarations = true } = {},
    ) {
      const allowed = new Set(Array.isArray(allowedNames) ? allowedNames : []);
      const elementLabel = element?.tagName || element?.nodeName || "element";
      const attributeNames = Array.from(element?.attributes || [])
        .filter((attribute) => {
          if (!allowNamespaceDeclarations) return true;
          const attributeName = attribute?.name || "";
          return (
            attributeName !== "xmlns" && !attributeName.startsWith("xmlns:")
          );
        })
        .map((attribute) => attribute.name);

      attributeNames.forEach((attributeName) => {
        if (!allowed.has(attributeName)) {
          throw new Error(
            `${context}: unexpected attribute "${attributeName}" on <${elementLabel}>`,
          );
        }
      });

      return attributeNames;
    },

    /**
     * Require a non-empty attribute value on an element.
     * @param {Element} element XML element to inspect.
     * @param {string} attributeName Attribute name to read.
     * @param {string} [context] Context label for errors.
     * @returns {string} Trimmed attribute value.
     * @throws {Error} Throws when the attribute is missing or empty.
     * @since 2.39.0
     */
    getRequiredAttribute(element, attributeName, context = "XML response") {
      const value = element?.getAttribute?.(attributeName)?.trim() || "";
      const elementLabel = element?.tagName || element?.nodeName || "element";
      if (value) return value;

      throw new Error(
        `${context}: <${elementLabel}> is missing required "${attributeName}" attribute`,
      );
    },

    /**
     * Require that direct child elements match an ordered schema-like sequence,
     * including min/max occurrence constraints.
     * @param {Node} node Parent XML node.
     * @param {Array.<object>} definitions Ordered direct-child definitions
     * @param {string} definitions[].name Child element name
     * @param {number} [definitions[].minOccurs] Minimum occurrences
     * @param {number} [definitions[].maxOccurs] Maximum occurrences
     * @param {string} [context] Context label for errors.
     * @returns {Map<string, Element[]>} Matching child elements by local name
     * @throws {Error} Throws on unexpected, out-of-order, duplicate, or missing
     * child elements.
     * @since 2.39.0
     */
    requireDirectChildSequence(node, definitions, context = "XML response") {
      const childElements = Array.from(node?.children || []);
      const parentLabel = node?.tagName || node?.nodeName || "element";
      const normalizedDefinitions = Array.isArray(definitions)
        ? definitions.map((definition) => ({
            name: String(definition?.name || "").toLowerCase(),
            minOccurs: definition?.minOccurs ?? 0,
            maxOccurs: definition?.maxOccurs ?? 1,
          }))
        : [];
      const definitionMap = new Map();
      normalizedDefinitions.forEach((definition, index) => {
        definitionMap.set(definition.name, { ...definition, index });
      });

      const matches = new Map();
      let previousIndex = -1;

      childElements.forEach((child) => {
        const childName = this.getNormalizedElementName(child);
        const childLabel = child?.tagName || child?.nodeName || childName;
        const definition = definitionMap.get(childName);

        if (!definition) {
          throw new Error(
            `${context}: unexpected <${childLabel}> under <${parentLabel}>`,
          );
        }

        if (definition.index < previousIndex) {
          throw new Error(
            `${context}: <${childLabel}> is out of order under <${parentLabel}>`,
          );
        }

        previousIndex = definition.index;
        const existing = matches.get(childName) || [];
        existing.push(child);
        matches.set(childName, existing);

        if (
          definition.maxOccurs !== Infinity &&
          existing.length > definition.maxOccurs
        ) {
          throw new Error(
            `${context}: <${childLabel}> may occur at most ${definition.maxOccurs} time(s) under <${parentLabel}>`,
          );
        }
      });

      normalizedDefinitions.forEach((definition) => {
        const count = matches.get(definition.name)?.length || 0;
        if (count < definition.minOccurs) {
          throw new Error(
            `${context}: missing required <${definition.name}> under <${parentLabel}>`,
          );
        }
      });

      return matches;
    },

    /**
     * Require that the provided document or element has the expected root name.
     * Matching is case-insensitive and ignores namespace prefixes.
     * @param {Document|Element} documentOrElement Parsed XML document or root
     * element.
     * @param {string} expectedName Expected root local name.
     * @param {string} [context] Context label for error
     * messages.
     * @returns {Element} The validated root element.
     * @throws {Error} Throws when the root is missing or has the wrong name.
     * @since 2.39.0
     */
    requireDocumentElement(
      documentOrElement,
      expectedName,
      context = "XML response",
    ) {
      let root = null;
      if (documentOrElement?.documentElement) {
        root = documentOrElement.documentElement;
      } else if (documentOrElement?.nodeType === 1) {
        root = documentOrElement;
      }

      if (!root) {
        throw new Error(`${context}: parsed XML document is required`);
      }

      const actualName = this.getNormalizedElementName(root);
      const normalizedExpected = String(expectedName || "").toLowerCase();
      if (actualName !== normalizedExpected) {
        const actualLabel =
          root.tagName || root.nodeName || actualName || "unknown";
        throw new Error(
          `${context}: expected root <${expectedName}> but found <${actualLabel}>`,
        );
      }

      return root;
    },

    /**
     * Extract the first non-empty text value from a given element. The element
     * is matched by local name and searched for anywhere under the given
     * document or node. Throws an error if the element is not found or if the
     * text value is empty.
     * @param {Document|Node} documentOrNode Parsed XML document or node.
     * @param {string} elementName Local element name to extract.
     * @param {string} [context] Context label for error
     * messages.
     * @returns {string} The extracted non-empty text value.
     * @throws {Error} When the document or required text is missing
     * @since 2.39.0
     */
    getRequiredElementText(
      documentOrNode,
      elementName,
      context = "XML response",
    ) {
      if (
        !documentOrNode ||
        typeof documentOrNode.getElementsByTagName !== "function"
      ) {
        throw new Error(`${context}: parsed XML document is required`);
      }

      const element = this.findFirstElement(documentOrNode, elementName);
      const value = element?.textContent?.trim() || null;

      if (value) {
        return value;
      }

      throw new Error(
        `${context}: missing ${elementName} element in XML response`,
      );
    },

    /**
     * Parse an XML response and extract the first non-empty text value for the
     * requested element.
     * @param {string} xmlString XML response text.
     * @param {string} elementName Local element name to extract.
     * @param {string} [context] Context label for error
     * messages.
     * @returns {{value:string, xml:Document}} Parsed XML and extracted value.
     * @since 2.39.0
     */
    parseXmlStringForRequiredElementText(
      xmlString,
      elementName,
      context = "XML response",
    ) {
      const xml = this.parseRequiredXmlString(xmlString, context);

      return {
        value: this.getRequiredElementText(xml, elementName, context),
        xml,
      };
    },

    /**
     * Append a child element with XML-safe text content.
     * @param {XMLDocument} doc XML document used to create elements.
     * @param {Element} parent Parent element that receives the child.
     * @param {string} name Child element name.
     * @param {*} value Text value to append.
     * @returns {Element|null} The appended child element, or null for nullish
     * values.
     * @since 2.39.0
     */
    appendTextElement(doc, parent, name, value) {
      if (value === null || value === undefined) return null;

      const element = doc.createElement(name);
      element.textContent = this.removeInvalidXmlCharacters(value);
      parent.appendChild(element);
      return element;
    },

    /**
     * Append a child element containing CDATA. Split `]]>` across CDATA nodes
     * because it cannot appear within a single CDATA section.
     * @param {XMLDocument} doc XML document used to create elements.
     * @param {Element} parent Parent element that receives the child.
     * @param {string} name Child element name.
     * @param {string} value Text value to append.
     * @returns {Element} The appended child element.
     */
    appendCdataElement(doc, parent, name, value) {
      const element = doc.createElement(name);
      const text = this.removeInvalidXmlCharacters(value);
      const parts = text.split("]]>");

      parts.forEach((part, index) => {
        const content = `${index ? ">" : ""}${part}${index < parts.length - 1 ? "]]" : ""}`;
        element.appendChild(doc.createCDATASection(content));
      });

      parent.appendChild(element);
      return element;
    },

    /**
     * Extract the XML declaration from raw XML text, if present.
     * @param {string|null|undefined} xmlString XML text to inspect.
     * @returns {string|null} XML declaration text.
     * @since 2.39.0
     */
    extractXmlDeclaration(xmlString) {
      if (!isString(xmlString) || !String(xmlString).trim()) return null;
      const match = String(xmlString).match(/^\s*(<\?xml\b[\s\S]*?\?>)/i);
      return match?.[1] || null;
    },

    /**
     * Capture namespace declarations from an XML element in source order.
     * @param {Element|null} element XML element to inspect.
     * @returns {Array<{name:string, value:string}>} Namespace attributes.
     * @since 2.39.0
     */
    getNamespaceAttributes(element) {
      return Array.from(element?.attributes || [])
        .filter((attribute) => {
          const attributeName = attribute?.name || "";
          return (
            attributeName === "xmlns" || attributeName.startsWith("xmlns:")
          );
        })
        .map((attribute) => ({
          name: attribute.name,
          value: attribute.value,
        }));
    },

    /**
     * Extract the first non-empty text matched by the provided selectors from
     * an XML string.
     * @param {string} xmlString XML text to search.
     * @param {string[]} selectors Selectors to try in order.
     * @returns {string} The extracted text, or an empty string.
     * @throws {Error} When XML parsing fails unexpectedly
     * @since 2.39.0
     */
    extractTextBySelectors(xmlString, selectors) {
      let xmlDoc = null;
      try {
        xmlDoc = this.parseXmlString(xmlString);
      } catch (e) {
        if (e instanceof ParseError) {
          // If parsing fails, we can still attempt to extract text using a
          // simple selector engine that doesn't rely on valid XML structure
          xmlDoc = null;
        } else {
          throw e;
        }
      }

      if (!xmlDoc || !Array.isArray(selectors)) return "";

      // Return the first non-empty text found for any selector
      let foundText = "";
      selectors.some((selector) => {
        let element = null;

        try {
          element = xmlDoc.querySelector(selector);
        } catch {
          element = null;
        }

        if (!element) {
          [element] = this.findElementsBySimpleSelector(xmlDoc, selector);
        }

        const text = element?.textContent?.trim();
        if (text) {
          foundText = text;
          return true; // exit loop once found
        }
        return false;
      });
      return foundText;
    },
  };

  /** Expose the XML parse error type. @type {typeof ParseError} */
  XMLUtilities.ParseError = ParseError;

  return XMLUtilities;
});