Source: src/js/models/viewService/ViewServiceDoc.js

define([
  "common/UrlUtilities",
  "common/ValueUtilities",
  "common/XMLUtilities",
], (UrlUtilities, ValueUtilities, XMLUtilities) => {
  // Classnames existing in the rertuned HTML that we want to reference in code,
  // plus classes we add to the HTML for styling and selection.
  const CLASS_NAMES = {
    active: "active",
    annotation: "annotation",
    attributeListTable: "attributeListTable",
    btn: "btn",
    citation: "citation",
    collapse: "collapse",
    container: "container",
    controlGroup: "control-group",
    controlLabel: "control-label",
    controls: "controls",
    controlsWell: "controls-well",
    copy: "copy",
    eastBoundingCoordinate: "eastBoundingCoordinate",
    entityDetails: "entitydetails",
    entityName: "entityName",
    formHorizontal: "form-horizontal",
    geographicCoverage: "geographicCoverage",
    geographicDescription: "geographicDescription",
    icon: "icon",
    iconInfoSign: "icon-info-sign",
    iconOk: "icon-ok",
    markdown: "markdown",
    northBoundingCoordinate: "northBoundingCoordinate",
    rowFluid: "row-fluid",
    southBoundingCoordinate: "southBoundingCoordinate",
    span10: "span10",
    span12: "span12",
    taxonomicCoverage: "taxonomicCoverage",
    tooltipThis: "tooltip-this",
    well: "well",
    westBoundingCoordinate: "westBoundingCoordinate",
  };

  const IDS = {
    downloadPackage: "downloadPackage",
    metadata: "Metadata",
    viewMetadataCitationLink: "viewMetadataCitationLink",
  };

  const STATUS = Object.freeze({
    OK: "ok",
    EMPTY: "empty",
    TRANSFORM_ERROR: "transformError",
    UNSTYLED: "unstyled",
  });

  const TRANSFORM_ERROR_TEXT = "Error transforming document";
  const ENTITY_SECTION_SELECTOR = `.${CLASS_NAMES.entityDetails}`;
  const ENTITY_PID_ATTRIBUTE = "data-id";
  const ENTITY_FILENAME_ATTRIBUTE = "data-filename";
  const ENTITY_OBJECT_NAME_ATTRIBUTE = "data-object-name";
  const ENTITY_NAME_ATTRIBUTE = "data-entity-name";

  const ECOGRID_PREFIX = "ecogrid://";

  /**
   * The ViewServiceDoc handles HTML returned from the MetacatUI view service.
   * It handles parsing, normalizing, and annotating the HTML, including
   * rewriting legacy ecogrid links, marking taxonomic coverage sections, and
   * marking the first row of attribute tables as active. It also collects
   * entity summaries from the HTML, including PIDs, file names, and object
   * URLs.
   * @class ViewServiceDoc
   * @classcategory Models/ViewService
   * @since 2.39.0
   */
  class ViewServiceDoc {
    /**
     * @param {object} [options] Constructor options
     * @param {string} [options.pid] PID or SID rendered by the view service
     * @param {string} [options.rawHtml] Raw HTML returned by the service
     * @param {string} [options.url] Final response URL
     * @param {string} [options.contentType] Response content type
     * @param {string} [options.resolveBaseUrl] Resolve URL used for legacy
     * ecogrid link rewrites.
     */
    constructor({
      pid = null,
      rawHtml = "",
      url = null,
      contentType = null,
      resolveBaseUrl = null,
    } = {}) {
      this.pid = ViewServiceDoc.normalizeIdentifier(pid);
      this.rawHtml = ViewServiceDoc.normalizeHtml(rawHtml);
      this.html = this.rawHtml;
      this.template = null;
      this.url = ValueUtilities.normalizeText(url);
      this.contentType = ValueUtilities.normalizeText(contentType);
      this.resolveBaseUrl = UrlUtilities.normalizeUrl(resolveBaseUrl);
      this.status = this.rawHtml.trim() ? STATUS.OK : STATUS.EMPTY;
      this.hasMetadataRoot = false;
      this.entities = [];
      this.entityPids = [];
      this.warnings = [];
      this.mutations = [];
    }

    /**
     * Parse and normalize the rendered HTML.
     * @param {string} [rawHtml] Raw HTML. Defaults to the current `rawHtml`
     * @param {object} [options] Parse options
     * @param {string} [options.resolveBaseUrl] Resolve URL for ecogrid links
     * @returns {ViewServiceDoc} This instance
     */
    parse(rawHtml = this.rawHtml, options = {}) {
      this.rawHtml = ViewServiceDoc.normalizeHtml(rawHtml);
      if (options.resolveBaseUrl) {
        this.resolveBaseUrl = UrlUtilities.normalizeUrl(options.resolveBaseUrl);
      }

      this.html = this.rawHtml;
      this.template = null;
      this.status = STATUS.OK;
      this.hasMetadataRoot = false;
      this.entities = [];
      this.entityPids = [];
      this.warnings = [];
      this.mutations = [];

      const trimmedHtml = this.rawHtml.trim();
      if (!trimmedHtml) {
        this.status = STATUS.EMPTY;
        return this;
      }

      if (ViewServiceDoc.isTransformError(trimmedHtml)) {
        this.status = STATUS.TRANSFORM_ERROR;
        return this;
      }

      const template = this.createTemplate();
      if (!template) return this;

      const root = template.content;
      this.hasMetadataRoot = Boolean(root.querySelector(`#${IDS.metadata}`));
      this.status = this.hasMetadataRoot ? STATUS.OK : STATUS.UNSTYLED;

      this.markLegacyTaxonomicCoverage(root);
      this.rewriteEcoGridLinks(root);
      this.markAttributeTableDefaults(root);
      this.annotateEntitySections(root);

      this.entities = this.collectEntities(root);
      this.entityPids = ValueUtilities.dedupeStrings(
        this.entities.map((entity) => entity.pid).filter(Boolean),
      );
      this.html = template.innerHTML;
      this.template = template;

      return this;
    }

    /**
     * Create a detached template for temporary HTML parsing.
     * @returns {HTMLTemplateElement|null} Parsed template, or null when the
     * current runtime does not provide DOM APIs.
     */
    createTemplate() {
      const template = globalThis.document.createElement("template");
      template.innerHTML = this.rawHtml;
      return template;
    }

    /**
     * Mark legacy taxonomic coverage sections for styling.
     * @param {DocumentFragment} root Detached parse root
     * @returns {number} Number of sections marked
     */
    markLegacyTaxonomicCoverage(root) {
      if (!root || root.querySelector(`.${CLASS_NAMES.taxonomicCoverage}`)) {
        return 0;
      }

      let count = 0;
      root.querySelectorAll("h4").forEach((heading) => {
        if (!ViewServiceDoc.textIncludes(heading, "Taxonomic Range")) return;
        const container = heading.parentElement;
        if (!container) return;
        container.classList.add(CLASS_NAMES.taxonomicCoverage);
        count += 1;
      });

      if (count) this.recordMutation("markLegacyTaxonomicCoverage", { count });
      return count;
    }

    /**
     * Rewrite legacy ecogrid links to resolve service links when configured.
     * @param {DocumentFragment} root Detached parse root
     * @returns {number} Number of links rewritten
     */
    rewriteEcoGridLinks(root) {
      const baseUrl = UrlUtilities.normalizeUrl(this.resolveBaseUrl);
      if (!root || !baseUrl) return 0;

      let count = 0;
      root.querySelectorAll("a").forEach((candidate) => {
        const link = candidate;
        const result = ViewServiceDoc.rewriteEcoGridLink(
          link.textContent,
          baseUrl,
        );
        if (!result) return;

        link.setAttribute("href", result.url);
        link.textContent = result.pid;
        count += 1;
      });

      if (count) this.recordMutation("rewriteEcoGridLinks", { count });
      return count;
    }

    /**
     * Rewrite one legacy ecogrid label to a resolve service URL.
     * @param {string} link Link text that may contain an ecogrid PID
     * @param {string} baseUrl Resolve service base URL
     * @returns {{pid:string,url:string}|false} Rewrite data, or false when the
     * link cannot be rewritten.
     */
    static rewriteEcoGridLink(link, baseUrl) {
      const pid = ViewServiceDoc.extractEcoGridPid(link);
      const normalizedBaseUrl = UrlUtilities.normalizeUrl(baseUrl);
      if (!pid || !normalizedBaseUrl) return false;
      const encodedPid = UrlUtilities.encodeDataONEPidForPath(pid);
      const url = UrlUtilities.buildUrl(normalizedBaseUrl, encodedPid, {
        encodePath: false,
      });
      return { pid, url };
    }

    /**
     * Mark the first row in each attribute list table active.
     * @param {DocumentFragment} root Detached parse root
     * @returns {number} Number of rows marked
     */
    markAttributeTableDefaults(root) {
      if (!root) return 0;

      let count = 0;
      root
        .querySelectorAll(`.${CLASS_NAMES.attributeListTable}`)
        .forEach((table) => {
          const row = table.querySelector("tr");
          if (!row) return;
          row.classList.add(CLASS_NAMES.active);
          count += 1;
        });

      if (count) this.recordMutation("markAttributeTableDefaults", { count });
      return count;
    }

    /**
     * Normalize and annotate entity sections using identifiers already present
     * in the rendered HTML.
     * @param {DocumentFragment} root Detached parse root
     * @returns {number} Number of sections annotated
     */
    annotateEntitySections(root) {
      if (!root) return 0;

      let count = 0;
      this.getCandidateEntitySections(root).forEach((section) => {
        if (this.annotateEntitySection(section)) count += 1;
      });

      if (count) this.recordMutation("annotateEntitySections", { count });
      return count;
    }

    /**
     * Find possible entity sections in rendered metadata
     * @param {DocumentFragment} root Detached parse root
     * @returns {Element[]} Candidate entity section elements
     */
    getCandidateEntitySections(root) {
      if (!root) return [];

      const sections = [];
      const addSection = (section) => {
        if (!section || sections.includes(section)) return;
        sections.push(section);
      };

      root.querySelectorAll(ENTITY_SECTION_SELECTOR).forEach(addSection);

      root.querySelectorAll("a[data-pid]").forEach((link) => {
        addSection(this.constructor.getEntityContainerForLink(link));
      });

      root.querySelectorAll("[data-object-name]").forEach((element) => {
        addSection(element.closest(ENTITY_SECTION_SELECTOR));
      });

      root
        .querySelectorAll(`.${CLASS_NAMES.entityName}[data-entity-name]`)
        .forEach((name) => {
          addSection(name.closest(ENTITY_SECTION_SELECTOR));
        });

      return sections;
    }

    /**
     * Add entity details class and useful data attributes to one section.
     * @param {Element} section Entity section element
     * @param {string} [pid] PID to set. Falls back to identifiers already
     * present in the section.
     * @returns {string|null} A selector for the annotated section
     */
    annotateEntitySection(section, pid = null) {
      if (!section?.classList) return null;
      const normalizedPid =
        ViewServiceDoc.normalizeIdentifier(pid) ||
        this.inferEntityPidFromSection(section);
      const entityName = this.getEntityName(section);
      const objectName = this.getObjectName(section);
      const fileName = objectName;

      section.classList.add(CLASS_NAMES.entityDetails);
      if (normalizedPid) {
        section.setAttribute(ENTITY_PID_ATTRIBUTE, normalizedPid);
      }
      if (fileName) {
        section.setAttribute(ENTITY_FILENAME_ATTRIBUTE, fileName);
        section.setAttribute(ENTITY_OBJECT_NAME_ATTRIBUTE, fileName);
      }
      if (entityName) {
        section.setAttribute(ENTITY_NAME_ATTRIBUTE, entityName);
      }

      return ViewServiceDoc.getSelector({
        pid: normalizedPid,
        fileName,
        entityName,
      });
    }

    /**
     * Infer an entity PID from markup inside an entity section.
     * @param {Element} section Candidate entity section
     * @returns {string|null} Normalized PID, or null when not found
     */
    inferEntityPidFromSection(section) {
      if (!section?.querySelector) return null;

      const dataId = ViewServiceDoc.normalizeIdentifier(
        section.getAttribute("data-id"),
      );
      if (dataId) return dataId;

      const pidLink = section.querySelector("a[data-pid]");
      const dataPid = ViewServiceDoc.normalizeIdentifier(
        pidLink?.getAttribute("data-pid"),
      );
      if (dataPid) return dataPid;

      const distributionPid =
        this.constructor.inferEntityPidFromDistributionLinks(section);
      if (distributionPid) return distributionPid;

      return null;
    }

    /**
     * Infer a PID from online distribution links inside a section.
     * @param {Element} section Candidate entity section
     * @returns {string|null} Normalized PID, or null when not found
     */
    static inferEntityPidFromDistributionLinks(section) {
      if (!section?.querySelectorAll) return null;

      const dataPidLink = Array.from(section.querySelectorAll("a")).find(
        (link) =>
          ViewServiceDoc.normalizeIdentifier(link.getAttribute("data-pid")),
      );
      if (dataPidLink) {
        return ViewServiceDoc.normalizeIdentifier(
          dataPidLink.getAttribute("data-pid"),
        );
      }

      const links = Array.from(section.querySelectorAll("a"));
      for (let i = 0; i < links.length; i += 1) {
        const pid = ViewServiceDoc.extractPidFromHref(
          links[i].getAttribute("href"),
        );
        if (pid) return pid;
      }

      return null;
    }

    /**
     * Find the section that describes one data entity.
     * @param {object} params Lookup params
     * @param {string} params.pid Entity PID
     * @param {string} [params.fileName] Entity file name
     * @param {string} [params.metadataPid] Current metadata PID
     * @param {DocumentFragment} params.root Detached parse root
     * @returns {Element|null} Matching entity section
     */
    findEntitySection({
      pid,
      fileName = "",
      metadataPid = this.pid,
      root,
    } = {}) {
      if (!root) return null;

      const normalizedPid = ViewServiceDoc.normalizeIdentifier(pid);
      const normalizedFileName = ValueUtilities.normalizeText(fileName);
      if (!normalizedPid && !normalizedFileName) return null;

      if (!normalizedPid) {
        return this.findEntitySectionByFileName(root, normalizedFileName);
      }

      const existing = this.constructor.findEntitySectionByDataId(
        root,
        normalizedPid,
      );
      if (existing) return existing;

      if (
        metadataPid &&
        normalizedPid === ViewServiceDoc.normalizeIdentifier(metadataPid)
      ) {
        return root.querySelector(`#${IDS.metadata}`) || null;
      }

      const linked = this.constructor.findEntitySectionByPidLink(
        root,
        normalizedPid,
      );
      if (linked) return linked;

      const anchored = this.findEntitySectionByAnchor(root, normalizedPid);
      if (anchored) return anchored;

      const onlineDistribution = this.findEntitySectionByDistributionLink(
        root,
        normalizedPid,
      );
      if (onlineDistribution) return onlineDistribution;

      const named = this.findEntitySectionByFileName(root, fileName);
      if (named) return named;

      return null;
    }

    /**
     * Find and annotate the section that describes one data entity.
     * @param {object} params Lookup params
     * @returns {Element|null} Matching annotated entity section
     */
    findAndAnnotateEntitySection(params = {}) {
      const section = this.findEntitySection(params);
      if (!section) return null;

      this.annotateEntitySection(section, params.pid);
      return section;
    }

    /**
     * Find an entity section by standardized data attributes.
     * @param {DocumentFragment|Element} root Search root
     * @param {object} criteria Selector criteria
     * @returns {Element|null} Matching section
     */
    static findEntitySectionBySelector(root, criteria = {}) {
      const selector = ViewServiceDoc.getSelector(criteria);
      if (!root?.querySelector || !selector) return null;
      if (root.matches?.(selector)) return root;
      return root.querySelector(selector);
    }

    /**
     * Find an entity section by existing data-id.
     * @param {DocumentFragment} root Detached parse root
     * @param {string} pid Normalized PID
     * @returns {Element|null} Matching section
     */
    static findEntitySectionByDataId(root, pid) {
      const xmlSafePid = XMLUtilities.getXMLSafeID(pid);
      if (
        root.matches?.(ENTITY_SECTION_SELECTOR) &&
        ViewServiceDoc.dataIdMatchesPid(
          root.getAttribute("data-id"),
          pid,
          xmlSafePid,
        )
      ) {
        return root;
      }

      const sections = Array.from(
        root.querySelectorAll(ENTITY_SECTION_SELECTOR),
      );
      return (
        sections.find((section) =>
          ViewServiceDoc.dataIdMatchesPid(
            section.getAttribute("data-id"),
            pid,
            xmlSafePid,
          ),
        ) || null
      );
    }

    /**
     * Find an entity section containing a link with data-pid.
     * @param {DocumentFragment} root Detached parse root
     * @param {string} pid Normalized PID
     * @returns {Element|null} Matching section
     */
    static findEntitySectionByPidLink(root, pid) {
      const link = Array.from(
        root.querySelectorAll(`${ENTITY_SECTION_SELECTOR} a`),
      ).find(
        (candidate) =>
          ViewServiceDoc.normalizeIdentifier(
            candidate.getAttribute("data-pid"),
          ) === pid,
      );
      return link?.closest(ENTITY_SECTION_SELECTOR) || null;
    }

    /**
     * Find an entity section by anchor ID.
     * @param {DocumentFragment} root Detached parse root
     * @param {string} pid Normalized PID
     * @returns {Element|null} Matching section
     */
    findEntitySectionByAnchor(root, pid) {
      const xmlSafePid = XMLUtilities.getXMLSafeID(pid);
      const link = Array.from(root.querySelectorAll("a")).find((candidate) => {
        const id = candidate.getAttribute("id");
        return id === pid || id === xmlSafePid;
      });

      return link ? this.constructor.getEntityContainerForLink(link) : null;
    }

    /**
     * Find an entity section by a legacy Online Distribution Info link.
     * @param {DocumentFragment} root Detached parse root
     * @param {string} pid Normalized PID
     * @returns {Element|null} Matching section
     */
    findEntitySectionByDistributionLink(root, pid) {
      const labels = Array.from(
        root.querySelectorAll(`.${CLASS_NAMES.controlLabel}`),
      ).filter((label) =>
        ViewServiceDoc.textIncludes(label, "Online Distribution Info"),
      );

      for (let i = 0; i < labels.length; i += 1) {
        const well = labels[i].nextElementSibling;
        if (well?.classList?.contains(CLASS_NAMES.controlsWell)) {
          const link = Array.from(well.querySelectorAll("a")).find(
            (candidate) =>
              ViewServiceDoc.hrefMatchesPid(
                candidate.getAttribute("href"),
                pid,
              ),
          );
          if (link) return this.constructor.getEntityContainerForLink(link);
        }
      }

      return null;
    }

    /**
     * Find an entity section by object or entity file name.
     * @param {DocumentFragment} root Detached parse root
     * @param {string} fileName File name
     * @returns {Element|null} Matching section
     */
    findEntitySectionByFileName(root, fileName) {
      const normalizedFileName = ValueUtilities.normalizeText(fileName);
      if (!normalizedFileName) return null;

      const selected = this.constructor.findEntitySectionBySelector(root, {
        fileName: normalizedFileName,
      });
      if (selected) return selected;

      const dataObjectNameMatch = Array.from(
        root.querySelectorAll(`${ENTITY_SECTION_SELECTOR} [data-object-name]`),
      ).find(
        (candidate) =>
          ValueUtilities.normalizeText(
            candidate.getAttribute("data-object-name"),
          ) === normalizedFileName,
      );
      if (dataObjectNameMatch) {
        return dataObjectNameMatch.closest(ENTITY_SECTION_SELECTOR);
      }

      const labels = Array.from(
        root.querySelectorAll(
          `${ENTITY_SECTION_SELECTOR} .${CLASS_NAMES.controlLabel}`,
        ),
      ).filter(
        (label) =>
          ViewServiceDoc.textIncludes(label, "Object Name") ||
          ViewServiceDoc.textIncludes(label, "Entity Name"),
      );

      for (let i = 0; i < labels.length; i += 1) {
        const well = labels[i].nextElementSibling;
        if (
          well?.classList?.contains(CLASS_NAMES.controlsWell) &&
          ViewServiceDoc.textIncludes(well, normalizedFileName)
        ) {
          const section = labels[i].closest(ENTITY_SECTION_SELECTOR);
          if (section) return section;
        }
      }

      return null;
    }

    /**
     * Return the nearest entity container for a link, promoting a form section
     * when needed to match legacy view-service markup.
     * @param {Element} link Link element
     * @returns {Element|null} Entity container
     */
    static getEntityContainerForLink(link) {
      const existingContainer = link.closest(ENTITY_SECTION_SELECTOR);
      if (existingContainer) return existingContainer;

      const form = link.closest("form");
      if (!form) return null;

      let container = link;
      while (container.parentElement && container.parentElement !== form) {
        container = container.parentElement;
      }
      if (container.parentElement !== form) return null;

      return container;
    }

    /**
     * Collect entity summaries from annotated sections.
     * @param {DocumentFragment} root Detached parse root
     * @returns {object[]} Plain entity summaries
     */
    collectEntities(root) {
      if (!root) return [];

      const seen = new Set();
      const entities = [];

      this.getCandidateEntitySections(root).forEach((section, index) => {
        const entity = this.summarizeEntitySection(section, index);
        const key =
          entity.pid ||
          entity.objectName ||
          entity.entityName ||
          entity.sectionId ||
          String(index);
        if (seen.has(key)) return;
        seen.add(key);
        entities.push(entity);
      });

      return entities;
    }

    /**
     * Build a plain summary from one entity section.
     * @param {Element} section Entity section
     * @param {number} index Entity index
     * @returns {object} Entity summary
     */
    summarizeEntitySection(section, index = 0) {
      const pid =
        ViewServiceDoc.normalizeIdentifier(
          section?.getAttribute?.("data-id"),
        ) || this.inferEntityPidFromSection(section);
      const entityName = this.getEntityName(section);
      const objectName = this.getObjectName(section);
      const objectUrl = this.constructor.getEntityObjectUrl(section, pid);
      const selector = ViewServiceDoc.getSelector({
        pid,
        fileName: objectName,
        entityName,
      });

      return {
        pid,
        entityName,
        objectName,
        fileName: objectName,
        objectUrl,
        selector,
        sectionId: ValueUtilities.normalizeText(section?.getAttribute?.("id")),
        sectionIndex: index,
      };
    }

    /**
     * Get the entity display name from a section.
     * @param {Element} section Entity section
     * @returns {string|null} Entity name
     */
    getEntityName(section) {
      if (!section?.querySelector) return null;

      const annotatedEntityName = ValueUtilities.normalizeText(
        section.getAttribute(ENTITY_NAME_ATTRIBUTE),
      );
      if (annotatedEntityName) return annotatedEntityName;

      const entityName = ValueUtilities.normalizeText(
        section
          .querySelector(`.${CLASS_NAMES.entityName}[data-entity-name]`)
          ?.getAttribute("data-entity-name"),
      );
      if (entityName) return entityName;

      return this.constructor.getControlValue(section, "Entity Name");
    }

    /**
     * Get the object/file name from a section.
     * @param {Element} section Entity section
     * @returns {string|null} Object name
     */
    getObjectName(section) {
      if (!section?.querySelector) return null;

      const annotatedFileName = ValueUtilities.normalizeText(
        section.getAttribute(ENTITY_FILENAME_ATTRIBUTE),
      );
      if (annotatedFileName) return annotatedFileName;

      const objectName = ValueUtilities.normalizeText(
        section
          .querySelector("[data-object-name]")
          ?.getAttribute("data-object-name"),
      );
      if (objectName) return objectName;

      return this.constructor.getControlValue(section, "Object Name");
    }

    /**
     * Get a value from a legacy label/control-well pair.
     * @param {Element} section Entity section
     * @param {string} labelText Label text to match
     * @returns {string|null} Control value
     */
    static getControlValue(section, labelText) {
      if (!section?.querySelectorAll) return null;

      const label = Array.from(
        section.querySelectorAll(`.${CLASS_NAMES.controlLabel}`),
      ).find((candidate) => ViewServiceDoc.textIncludes(candidate, labelText));
      const well = label?.nextElementSibling;
      if (!well?.classList?.contains(CLASS_NAMES.controlsWell)) return null;

      return ValueUtilities.normalizeText(well.textContent);
    }

    /**
     * Get the object URL for an entity section.
     * @param {Element} section Entity section
     * @param {string} pid Entity PID
     * @returns {string|null} Object URL
     */
    static getEntityObjectUrl(section, pid) {
      if (!section?.querySelectorAll) return null;

      const links = Array.from(section.querySelectorAll("a"));
      const matched = links.find((link) => {
        const dataPid = ViewServiceDoc.normalizeIdentifier(
          link.getAttribute("data-pid"),
        );
        if (pid && dataPid === pid) return true;
        return pid
          ? ViewServiceDoc.hrefMatchesPid(link.getAttribute("href"), pid)
          : false;
      });

      return ValueUtilities.normalizeText(matched?.getAttribute("href"));
    }

    /**
     * Record a deterministic markup mutation.
     * @param {string} type Mutation type
     * @param {object} [details] Mutation details
     */
    recordMutation(type, details = {}) {
      this.mutations.push({ type, ...details });
    }

    /**
     * Record a parse warning.
     * @param {string} type Warning type
     * @param {object} [details] Warning details
     */
    recordWarning(type, details = {}) {
      this.warnings.push({ type, ...details });
    }

    /**
     * Check whether the rendered document has no content.
     * @returns {boolean} True when the document is empty
     */
    isEmpty() {
      return this.status === STATUS.EMPTY;
    }

    /**
     * Check whether the view service returned a transform error response.
     * @returns {boolean} True when parsing detected a transform error
     */
    hasTransformError() {
      return this.status === STATUS.TRANSFORM_ERROR;
    }

    /**
     * Check whether the rendered HTML is missing the expected metadata root.
     * @returns {boolean} True when the document is rendered but unstyled
     */
    isUnstyled() {
      return this.status === STATUS.UNSTYLED;
    }

    /**
     * Return a JSON-safe snapshot.
     * @returns {object} Plain document state
     */
    toJSON() {
      return {
        pid: this.pid,
        url: this.url,
        contentType: this.contentType,
        status: this.status,
        hasMetadataRoot: this.hasMetadataRoot,
        rawHtml: this.rawHtml,
        html: this.html,
        entities: this.entities.map((entity) => ({ ...entity })),
        entityPids: [...this.entityPids],
        warnings: this.warnings.map((warning) => ({ ...warning })),
        mutations: this.mutations.map((mutation) => ({ ...mutation })),
      };
    }

    /**
     * Create and parse a rendered metadata document from raw HTML.
     * @param {string} rawHtml Raw service response
     * @param {object} [options] Constructor and parse options
     * @returns {ViewServiceDoc} Parsed document
     */
    static fromHtml(rawHtml, options = {}) {
      return new ViewServiceDoc({
        ...options,
        rawHtml,
      }).parse();
    }

    /**
     * Normalize raw HTML input while preserving whitespace.
     * @param {*} value Candidate HTML
     * @returns {string} String HTML
     */
    static normalizeHtml(value) {
      if (value === undefined || value === null) return "";
      return String(value);
    }

    /**
     * Normalize view-service entity identifiers.
     * @param {*} value Candidate identifier
     * @returns {string|null} Normalized identifier, or null for empty input
     */
    static normalizeIdentifier(value) {
      let normalized = ValueUtilities.normalizeText(value);
      if (!normalized) return null;

      if (normalized.startsWith("urn-uuid-")) {
        normalized = normalized.replace("urn-uuid-", "urn:uuid:");
      }
      if (normalized.startsWith("doi-10.")) {
        normalized = normalized.replace("doi-10.", "doi:10.");
      }

      return normalized;
    }

    /**
     * Determine whether a response is a view-service transform error message.
     * @param {string} html Trimmed response text
     * @returns {boolean} True when the text matches the known transform error
     */
    static isTransformError(html) {
      return (
        typeof html === "string" &&
        html.length < 250 &&
        html.indexOf(TRANSFORM_ERROR_TEXT) > -1
      );
    }

    /**
     * Test whether element text includes a phrase.
     * @param {Element} element Element to inspect
     * @param {string} phrase Phrase to find
     * @returns {boolean} True when phrase is present
     */
    static textIncludes(element, phrase) {
      const text = element?.textContent || "";
      return text.indexOf(phrase) > -1;
    }

    /**
     * Build a standardized selector for annotated entity sections.
     * @param {object} [criteria] Selector criteria
     * @param {string} [criteria.pid] Entity PID
     * @param {string} [criteria.fileName] Entity file name
     * @param {string} [criteria.objectName] Object name alias
     * @param {string} [criteria.entityName] Entity name
     * @returns {string} CSS selector, or an empty string when no criteria
     * exist.
     */
    static getSelector({
      pid = null,
      fileName = null,
      objectName = null,
      entityName = null,
    } = {}) {
      const selectors = [];
      const normalizedPid = ViewServiceDoc.normalizeIdentifier(pid);
      const normalizedFileName = ValueUtilities.normalizeText(
        fileName || objectName,
      );
      const normalizedEntityName = ValueUtilities.normalizeText(entityName);

      if (normalizedPid) {
        selectors.push(
          `${ENTITY_SECTION_SELECTOR}[${ENTITY_PID_ATTRIBUTE}="${ViewServiceDoc.escapeAttributeValue(
            normalizedPid,
          )}"]`,
        );
      }

      if (normalizedFileName) {
        const escapedFileName =
          ViewServiceDoc.escapeAttributeValue(normalizedFileName);
        selectors.push(
          `${ENTITY_SECTION_SELECTOR}[${ENTITY_FILENAME_ATTRIBUTE}="${escapedFileName}"]`,
        );
        selectors.push(
          `${ENTITY_SECTION_SELECTOR}[${ENTITY_OBJECT_NAME_ATTRIBUTE}="${escapedFileName}"]`,
        );
      }

      if (normalizedEntityName) {
        selectors.push(
          `${ENTITY_SECTION_SELECTOR}[${ENTITY_NAME_ATTRIBUTE}="${ViewServiceDoc.escapeAttributeValue(
            normalizedEntityName,
          )}"]`,
        );
      }

      return ValueUtilities.dedupeStrings(selectors).join(", ");
    }

    /**
     * Escape a value for use inside a quoted CSS attribute selector.
     * @param {*} value Attribute value
     * @returns {string} Escaped attribute value
     */
    static escapeAttributeValue(value) {
      return String(value || "")
        .replace(/\\/g, "\\\\")
        .replace(/"/g, '\\"')
        .replace(/\n/g, "\\A ")
        .replace(/\r/g, "\\D ")
        .replace(/\f/g, "\\C ");
    }

    /**
     * Compare a data-id value to a PID or its legacy XML-safe form.
     * @param {string} value Candidate data-id
     * @param {string} pid Normalized PID
     * @param {string} xmlSafePid XML-safe PID
     * @returns {boolean} True when the values match
     */
    static dataIdMatchesPid(value, pid, xmlSafePid) {
      const normalizedValue = ViewServiceDoc.normalizeIdentifier(value);
      return normalizedValue === pid || value === xmlSafePid;
    }

    /**
     * Check whether an href contains a PID in one common encoded form.
     * @param {string} href Candidate href
     * @param {string} pid Normalized PID
     * @returns {boolean} True when href appears to reference the PID
     */
    static hrefMatchesPid(href, pid) {
      const normalizedHref = ValueUtilities.normalizeText(href);
      const normalizedPid = ViewServiceDoc.normalizeIdentifier(pid);
      if (!normalizedHref || !normalizedPid) return false;

      const hrefPid = ViewServiceDoc.extractPidFromHref(normalizedHref);
      if (hrefPid) return hrefPid === normalizedPid;

      const candidates = ValueUtilities.dedupeStrings([
        normalizedPid,
        encodeURIComponent(normalizedPid),
        UrlUtilities.encodeDataONEPidForPath(normalizedPid),
        XMLUtilities.getXMLSafeID(normalizedPid),
      ]);

      return candidates.some((candidate) => normalizedHref.includes(candidate));
    }

    /**
     * Extract a PID from a legacy ecogrid link label.
     * @param {string} text Link text
     * @returns {string|null} PID, or null when not an ecogrid label
     */
    static extractEcoGridPid(text) {
      const linkText = ValueUtilities.normalizeText(text);
      if (!linkText || linkText.indexOf(ECOGRID_PREFIX) === -1) return null;

      const withoutPrefix = linkText.substring(
        linkText.indexOf(ECOGRID_PREFIX) + ECOGRID_PREFIX.length,
      );
      const slashIndex = withoutPrefix.indexOf("/");
      const pid =
        slashIndex > -1
          ? withoutPrefix.substring(slashIndex + 1)
          : withoutPrefix;
      return ViewServiceDoc.normalizeIdentifier(pid);
    }

    /**
     * Extract a likely DataONE PID from an object or resolve href.
     * @param {string} href Candidate href
     * @returns {string|null} Normalized PID, or null when not found
     */
    static extractPidFromHref(href) {
      const normalizedHref = ValueUtilities.normalizeText(href);
      if (!normalizedHref) return null;

      const withoutSuffix =
        UrlUtilities.stripFragment(normalizedHref).split("?")[0];
      const match = withoutSuffix.match(/\/(?:object|resolve)\/([^/]+)$/i);
      if (!match?.[1]) return null;

      return ViewServiceDoc.normalizeIdentifier(
        UrlUtilities.decodeDataONEPidFromPath(match[1]),
      );
    }
  }

  ViewServiceDoc.CLASS_NAMES = CLASS_NAMES;
  ViewServiceDoc.IDS = IDS;
  ViewServiceDoc.STATUS = STATUS;

  return ViewServiceDoc;
});