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

define([
  "common/XMLUtilities",
  "common/ValueUtilities",
  "common/ValidationUtilities",
], (XMLUtilities, ValueUtilities, ValidationUtilities) => {
  const {
    normalizeBoolean,
    normalizeInteger,
    normalizeStringArray,
    isNonEmptyString,
    isNonNegativeInteger,
    normalizeText,
    requireNonNegativeInteger,
  } = ValueUtilities;
  const { fixParsedValue, createValidationIssue } = ValidationUtilities;
  const REPLICATION_POLICY_CHILD_SEQUENCE = [
    { name: "preferredMemberNode", minOccurs: 0, maxOccurs: Infinity },
    { name: "blockedMemberNode", minOccurs: 0, maxOccurs: Infinity },
  ];
  const NODE_TYPE_TO_FIELD = {
    preferred: "preferredNodes",
    blocked: "blockedNodes",
  };

  /**
   * Replication policy value object for System Metadata.
   * @class ReplicationPolicy
   * @classcategory Models/SysMeta
   * @since 2.39.0
   */
  class ReplicationPolicy {
    /**
     * Create a ReplicationPolicy instance.
     * @param {object} [data] Initial replication policy values.
     * @param {boolean|string|number|null} [data.replicationAllowed] Whether
     * replication is allowed.
     * @param {number|string|null} [data.numberReplicas] Requested replica
     * count.
     * @param {Array<string>} [data.preferredNodes] Preferred member nodes.
     * @param {Array<string>} [data.blockedNodes] Blocked member nodes.
     */
    constructor(data = {}) {
      this.replicationAllowed = normalizeBoolean(data.replicationAllowed);
      this.numberReplicas = normalizeInteger(data.numberReplicas);
      this.preferredNodes = normalizeStringArray(data.preferredNodes);
      this.blockedNodes = normalizeStringArray(data.blockedNodes);
    }

    /**
     * Normalize and validate a replication-node type.
     * @param {string} type Node-list type.
     * @returns {"preferred"|"blocked"} Canonical node-list type.
     * @throws {Error} When type is not `preferred` or `blocked`
     * @private
     */
    static requireNodeType(type) {
      const normalizedType = normalizeText(type)?.toLowerCase() || null;
      if (
        !Object.prototype.hasOwnProperty.call(
          NODE_TYPE_TO_FIELD,
          normalizedType,
        )
      ) {
        throw new Error(
          `ReplicationPolicy node type must be "preferred" or "blocked", got "${type}".`,
        );
      }
      return normalizedType;
    }

    /**
     * Resolve the array field for a replication-node type.
     * @param {"preferred"|"blocked"} type Node-list type.
     * @returns {"preferredNodes"|"blockedNodes"} Backing array field.
     * @throws {Error} When type is not `preferred` or `blocked`
     * @private
     */
    static getNodeField(type) {
      return NODE_TYPE_TO_FIELD[this.requireNodeType(type)];
    }

    /**
     * Append one preferred or blocked member node.
     * @param {string} node Member node identifier.
     * @param {"preferred"|"blocked"} type Node-list type.
     * @returns {ReplicationPolicy} The same policy instance.
     * @throws {Error} When type is not `preferred` or `blocked`
     */
    add(node, type) {
      const field = ReplicationPolicy.getNodeField(type);
      this[field].push(node);
      this[field] = normalizeStringArray(this[field]);
      return this;
    }

    /**
     * Replace one preferred or blocked member node.
     * @param {number} index Node index to replace.
     * @param {string} node Replacement member node.
     * @param {"preferred"|"blocked"} type Node-list type.
     * @returns {ReplicationPolicy} The same policy instance.
     * @throws {Error} When type or index is invalid
     */
    replace(index, node, type) {
      const normalizedIndex = requireNonNegativeInteger(index);
      const field = ReplicationPolicy.getNodeField(type);
      if (normalizedIndex >= this[field].length) {
        throw new Error(
          `ReplicationPolicy.replace could not find ${type} node at index ${normalizedIndex}.`,
        );
      }
      this[field][normalizedIndex] = node;
      this[field] = normalizeStringArray(this[field]);
      return this;
    }

    /**
     * Remove one preferred or blocked member node.
     * @param {number} index Node index to remove.
     * @param {"preferred"|"blocked"} type Node-list type.
     * @returns {ReplicationPolicy} The same policy instance.
     * @throws {Error} When type or index is invalid
     */
    remove(index, type) {
      const normalizedIndex = requireNonNegativeInteger(index);
      const field = ReplicationPolicy.getNodeField(type);
      if (normalizedIndex >= this[field].length) {
        throw new Error(
          `ReplicationPolicy.remove could not find ${type} node at index ${normalizedIndex}.`,
        );
      }
      this[field].splice(normalizedIndex, 1);
      return this;
    }

    /**
     * Clear one or both preferred/blocked node lists and optionally the
     * scalar policy values.
     * @param {"preferred"|"blocked"} [type] Node-list type to clear.
     * @returns {ReplicationPolicy} The same policy instance.
     * @throws {Error} When type is not `preferred` or `blocked`
     */
    clear(type) {
      if (type === undefined) {
        this.replicationAllowed = null;
        this.numberReplicas = null;
        this.preferredNodes = [];
        this.blockedNodes = [];
        return this;
      }
      this[ReplicationPolicy.getNodeField(type)] = [];
      return this;
    }

    /**
     * Normalize the policy in place.
     * @returns {ReplicationPolicy} The same policy instance.
     */
    normalize() {
      this.replicationAllowed = normalizeBoolean(this.replicationAllowed);
      this.numberReplicas = normalizeInteger(this.numberReplicas);
      this.preferredNodes = normalizeStringArray(this.preferredNodes);
      this.blockedNodes = normalizeStringArray(this.blockedNodes);
      return this;
    }

    /**
     * Create a ReplicationPolicy from a `replicationPolicy` XML element.
     * @param {Element} element XML element to parse.
     * @returns {ReplicationPolicy} Parsed replication policy instance.
     */
    static fromElement(element) {
      const context = "ReplicationPolicy XML";
      XMLUtilities.requireAllowedAttributeNames(
        element,
        ["replicationAllowed", "numberReplicas"],
        context,
      );
      XMLUtilities.requireDirectChildSequence(
        element,
        REPLICATION_POLICY_CHILD_SEQUENCE,
        context,
      );

      return new ReplicationPolicy({
        replicationAllowed: element.getAttribute("replicationAllowed"),
        numberReplicas: element.getAttribute("numberReplicas"),
        preferredNodes: XMLUtilities.getDirectChildTexts(
          element,
          "preferredMemberNode",
        ),
        blockedNodes: XMLUtilities.getDirectChildTexts(
          element,
          "blockedMemberNode",
        ),
      });
    }

    /**
     * Parse a `replicationPolicy` XML element, dropping it when invalid.
     * Unknown child nodes and ordering issues are ignored silently.
     * @param {Element|null} element XML element to parse.
     * @param {Array<object>} [parseWarnings] Lossy parse warnings.
     * @param {string} [path] Field path used in warnings and validation.
     * @returns {ReplicationPolicy} Parsed policy or an empty policy when
     * invalid.
     */
    static parse(element, parseWarnings = [], path = "replicationPolicy") {
      if (!element) return new ReplicationPolicy();

      const policy = ReplicationPolicy.fromValue({
        replicationAllowed: element.getAttribute("replicationAllowed"),
        numberReplicas: element.getAttribute("numberReplicas"),
        preferredNodes: XMLUtilities.getDirectChildTexts(
          element,
          "preferredMemberNode",
        ),
        blockedNodes: XMLUtilities.getDirectChildTexts(
          element,
          "blockedMemberNode",
        ),
      });
      return fixParsedValue({
        value: policy,
        path,
        parseWarnings,
        invalidMessage:
          "Ignored invalid replicationPolicy content while parsing system metadata.",
        validate: (candidate) => candidate.validate(path),
        fallback: () => new ReplicationPolicy(),
      });
    }

    /**
     * Coerce unknown input into a ReplicationPolicy instance.
     * @param {ReplicationPolicy|object|null|undefined} value Replication
     * policy-like input.
     * @returns {ReplicationPolicy} Normalized policy.
     */
    static fromValue(value) {
      if (value === undefined || value === null) return new ReplicationPolicy();
      return value instanceof ReplicationPolicy
        ? new ReplicationPolicy(value.toJSON())
        : new ReplicationPolicy(value);
    }

    /**
     * Whether the policy contains any serialized values.
     * @returns {boolean} `true` when any field is populated.
     */
    hasValues() {
      return (
        this.replicationAllowed !== null ||
        this.numberReplicas !== null ||
        this.preferredNodes.length > 0 ||
        this.blockedNodes.length > 0
      );
    }

    /**
     * Validate the replication policy state.
     * @param {string} [path] Base path used in validation errors. Defaults to
     * `replicationPolicy`.
     * @returns {Array<object>} Validation errors for invalid fields.
     */
    validate(path = "replicationPolicy") {
      const errors = [];

      if (
        this.replicationAllowed !== null &&
        typeof this.replicationAllowed !== "boolean"
      ) {
        errors.push(
          createValidationIssue({
            field: `${path}.replicationAllowed`,
            message: "replicationAllowed must be a boolean when present.",
          }),
        );
      }

      if (
        this.numberReplicas !== null &&
        !isNonNegativeInteger(this.numberReplicas)
      ) {
        errors.push(
          createValidationIssue({
            field: `${path}.numberReplicas`,
            message:
              "numberReplicas must be a non-negative integer when present.",
          }),
        );
      }

      this.preferredNodes.forEach((node, index) => {
        if (!isNonEmptyString(node)) {
          errors.push(
            createValidationIssue({
              field: `${path}.preferredNodes[${index}]`,
              message: "preferredMemberNode values must be non-empty strings.",
            }),
          );
        }
      });

      this.blockedNodes.forEach((node, index) => {
        if (!isNonEmptyString(node)) {
          errors.push(
            createValidationIssue({
              field: `${path}.blockedNodes[${index}]`,
              message: "blockedMemberNode values must be non-empty strings.",
            }),
          );
        }
      });

      return errors;
    }

    /**
     * Serialize the replication policy to XML.
     * @param {XMLDocument} doc XML document used to create elements.
     * @returns {Element|null} Serialized XML element, or `null` when empty.
     */
    toElement(doc) {
      if (!this.hasValues()) return null;

      const element = doc.createElement("replicationPolicy");

      if (this.replicationAllowed !== null) {
        element.setAttribute(
          "replicationAllowed",
          String(this.replicationAllowed),
        );
      }

      if (this.numberReplicas !== null) {
        element.setAttribute("numberReplicas", String(this.numberReplicas));
      }

      this.preferredNodes.forEach((node) => {
        XMLUtilities.appendTextElement(
          doc,
          element,
          "preferredMemberNode",
          node,
        );
      });

      this.blockedNodes.forEach((node) => {
        XMLUtilities.appendTextElement(doc, element, "blockedMemberNode", node);
      });

      return element;
    }

    /**
     * Return a JSON-serializable representation of the replication policy.
     * @returns {object} Plain replication policy data.
     */
    toJSON() {
      return {
        replicationAllowed: this.replicationAllowed,
        numberReplicas: this.numberReplicas,
        preferredNodes: [...this.preferredNodes],
        blockedNodes: [...this.blockedNodes],
      };
    }
  }

  return ReplicationPolicy;
});