Source: meta_serialization.js

/**
 * Serialization logic for substance metadata CSV import/export.
 *
 * Provides classes and utilities for converting between SubstanceMetadata
 * objects and CSV format for bulk import/export operations.
 *
 * @license BSD, see LICENSE.md
 */

import {
  SubstanceMetadata,
  SubstanceMetadataBuilder,
  Program,
  Application,
  Substance,
  SubstanceBuilder,
  Command,
  RetireCommand,
} from "ui_translator_components";
import {EngineNumber} from "engine_number";

/**
 * Standard CSV column order for substance metadata export/import.
 * Used consistently across serialization and deserialization operations.
 */
const META_COLUMNS = [
  "substance",
  "equipment",
  "application",
  "ghg",
  "hasDomestic",
  "hasImport",
  "hasExport",
  "energy",
  "initialChargeDomestic",
  "initialChargeImport",
  "initialChargeExport",
  "retirement",
  "retirementWithReplacement",
  "defaultSales",
  "key",
];

/**
 * Examples of unit value formats for parsing.
 */
const UNIT_VALUE_EXAMPLES = "+1,500.25% kwh / unit";

/**
 * Regex for parsing unit values with numeric component and units.
 * Matches: optional sign, digits with optional commas, optional decimal,
 * optional %, space, non-whitespace units
 */
const UNIT_VALUE_REGEX = /^([+-]?[\d,]+(?:\.\d+)?%?)\s+(\S.*)$/;

/**
 * Regex for matching number with optional commas, decimals, and % followed by units.
 */
const UNIT_VALUE_REGEX_FULL = /^([+-]?[\d,]+(?:\.\d+)?%?)\s+(.+)$/;

/**
 * Boolean value mapping for CSV parsing.
 * Maps various string representations to boolean values.
 * Throws exception if value not found in either true or false sets.
 */
const BOOLEAN_VALUES = new Map([
  ["t", true],
  ["1", true],
  ["y", true],
  ["true", true],
  ["yes", true],
  ["f", false],
  ["0", false],
  ["n", false],
  ["false", false],
  ["no", false],
]);

/**
 * Mapping from CSV defaultSales values to internal assumeMode values.
 * Supports both user-facing values (from exports) and internal values (backward compatibility).
 * Used during CSV import to normalize defaultSales to internal representation.
 */
const DEFAULT_SALES_TO_ASSUME_MODE = new Map([
  // User-facing values (from Component 1 exports)
  ["continue from prior year", "continued"],
  ["only servicing", "only recharge"],
  ["none", "no"],
  // Internal values (for backward compatibility and programmatic use)
  ["continued", "continued"],
  ["only recharge", "only recharge"],
  ["no", "no"],
]);

/**
 * Valid defaultSales values for error reporting.
 * Ordered: user-facing values first, then internal values.
 */
const VALID_DEFAULT_SALES_VALUES = [
  "continue from prior year",
  "only servicing",
  "none",
  "continued",
  "only recharge",
  "no",
];

/**
 * Result container for field-level parsing operations.
 *
 * Encapsulates the result of parsing a single field, distinguishing between
 * successful results with values and failed results with errors. Either getResult()
 * or getError() will be non-null, but not both.
 */
class FieldParseResult {
  /**
   * Create a successful field parse result.
   *
   * @param {*} result - The successfully parsed value
   */
  static success(result) {
    const instance = new FieldParseResult();
    instance._result = result;
    instance._error = null;
    return instance;
  }

  /**
   * Create a failed field parse result.
   *
   * @param {Error} error - The error that occurred during parsing
   */
  static failure(error) {
    const instance = new FieldParseResult();
    instance._result = null;
    instance._error = error;
    return instance;
  }

  /**
   * Get the parsed result value.
   *
   * @returns {*|null} The parsed value or null if parsing failed
   */
  getResult() {
    const self = this;
    return self._result;
  }

  /**
   * Get the parsing error if one occurred.
   *
   * @returns {Error|null} The error or null if parsing succeeded
   */
  getError() {
    const self = this;
    return self._error;
  }

  /**
   * Check if parsing resulted in an error.
   *
   * @returns {boolean} True if an error occurred
   */
  hasError() {
    const self = this;
    return self._error !== null;
  }
}

/**
 * Error information for substance metadata parsing issues.
 *
 * Provides structured error information that distinguishes between user data errors
 * (invalid CSV content) and system programming errors. Includes row and column
 * information to help users locate and fix issues in their data.
 */
class SubstanceMetadataError {
  /**
   * Create a new SubstanceMetadataError.
   *
   * @param {number} rowNumber - Row number where error occurred
   *   (0 for header, 1-based for data rows)
   * @param {string} column - Column name where error occurred
   * @param {string} message - Human-readable error description
   * @param {string} errorType - 'USER' for data errors or 'SYSTEM' for programming errors
   */
  constructor(rowNumber, column, message, errorType = "USER") {
    const self = this;
    self._rowNumber = rowNumber;
    self._column = column;
    self._message = message;
    self._errorType = errorType;
  }

  /**
   * Get the row number where the error occurred.
   *
   * @returns {number} Row number (0 for header, 1-based for data rows)
   */
  getRowNumber() {
    const self = this;
    return self._rowNumber;
  }

  /**
   * Get the column name where the error occurred.
   *
   * @returns {string} Column name
   */
  getColumn() {
    const self = this;
    return self._column;
  }

  /**
   * Get the human-readable error message.
   *
   * @returns {string} Error message
   */
  getMessage() {
    const self = this;
    return self._message;
  }

  /**
   * Get the error type.
   *
   * @returns {string} 'USER' or 'SYSTEM'
   */
  getErrorType() {
    const self = this;
    return self._errorType;
  }

  /**
   * Check if this is a user data error.
   *
   * @returns {boolean} True if this is a user error
   */
  isUserError() {
    const self = this;
    return self._errorType === "USER";
  }

  /**
   * Check if this is a system programming error.
   *
   * @returns {boolean} True if this is a system error
   */
  isSystemError() {
    const self = this;
    return self._errorType === "SYSTEM";
  }

  /**
   * Convert error to human-readable string.
   *
   * @returns {string} Formatted error description
   */
  toString() {
    const self = this;
    if (self._rowNumber === 0) {
      return `Header error in column '${self._column}': ${self._message}`;
    }
    return `Row ${self._rowNumber}, column '${self._column}': ${self._message}`;
  }
}

/**
 * Result container for substance metadata parsing operations.
 *
 * Contains successfully parsed metadata updates and any errors that occurred
 * during parsing. Provides methods to check for different types of errors
 * and access parsed results.
 */
class SubstanceMetadataParseResult {
  /**
   * Create a new SubstanceMetadataParseResult.
   *
   * @param {SubstanceMetadataUpdate[]} updates - Array of successfully parsed updates
   * @param {SubstanceMetadataError[]} errors - Array of parsing errors
   */
  constructor(updates = [], errors = []) {
    const self = this;
    self._updates = updates;
    self._errors = errors;
  }

  /**
   * Get the successfully parsed metadata updates.
   *
   * @returns {SubstanceMetadataUpdate[]} Array of parsed updates
   */
  getUpdates() {
    const self = this;
    return self._updates;
  }

  /**
   * Get all parsing errors.
   *
   * @returns {SubstanceMetadataError[]} Array of errors
   */
  getErrors() {
    const self = this;
    return self._errors;
  }

  /**
   * Check if any errors occurred during parsing.
   *
   * @returns {boolean} True if errors exist
   */
  hasErrors() {
    const self = this;
    return self._errors.length > 0;
  }

  /**
   * Check if any user data errors occurred.
   *
   * @returns {boolean} True if user errors exist
   */
  hasUserErrors() {
    const self = this;
    return self._errors.some((e) => e.isUserError());
  }

  /**
   * Check if any system errors occurred.
   *
   * @returns {boolean} True if system errors exist
   */
  hasSystemErrors() {
    const self = this;
    return self._errors.some((e) => e.isSystemError());
  }

  /**
   * Check if parsing was completely successful.
   *
   * @returns {boolean} True if no errors occurred
   */
  isSuccess() {
    const self = this;
    return self._errors.length === 0;
  }

  /**
   * Get only user data errors.
   *
   * @returns {SubstanceMetadataError[]} Array of user errors
   */
  getUserErrors() {
    const self = this;
    return self._errors.filter((e) => e.isUserError());
  }

  /**
   * Get only system programming errors.
   *
   * @returns {SubstanceMetadataError[]} Array of system errors
   */
  getSystemErrors() {
    const self = this;
    return self._errors.filter((e) => e.isSystemError());
  }

  /**
   * Add a successfully parsed metadata update.
   *
   * @param {SubstanceMetadataUpdate} update - Update to add
   */
  addUpdate(update) {
    const self = this;
    self._updates.push(update);
  }

  /**
   * Add a parsing error.
   *
   * @param {SubstanceMetadataError} error - Error to add
   */
  addError(error) {
    const self = this;
    self._errors.push(error);
  }

  /**
   * Get a summary of all errors as a formatted string.
   *
   * @returns {string} Multi-line error summary
   */
  getErrorSummary() {
    const self = this;
    if (self._errors.length === 0) return "No errors";
    return self._errors.map((e) => e.toString()).join("\n");
  }
}

/**
 * Serializer for converting SubstanceMetadata objects to CSV format.
 *
 * This class handles the conversion of SubstanceMetadata arrays to CSV-compatible
 * Maps and generates data URIs for browser downloads. The CSV format follows the
 * specification defined in the import_export_meta task.
 */
class MetaSerializer {
  /**
   * Helper function to get value or return empty string.
   *
   * @param {*} value - The value to check
   * @returns {string} The value or empty string if null/undefined
   * @private
   */
  _getOrEmpty(value) {
    const self = this;
    // Return empty string only for null/undefined, preserve "0" and other falsy values
    return value === null || value === undefined ? "" : String(value);
  }

  /**
   * Convert array of SubstanceMetadata to array of Maps for CSV export.
   *
   * Each Map represents a CSV row with column names as keys and string values.
   * The column structure matches the specification: substance, equipment, application,
   * ghg, hasDomestic, hasImport, hasExport, energy, initialChargeDomestic,
   * initialChargeImport, initialChargeExport, retirement, key.
   *
   * @param {SubstanceMetadata[]} metadataArray - Array of metadata objects
   * @returns {Array.<Map.<string, string>>} Array of Maps with CSV column mappings
   */
  serialize(metadataArray) {
    const self = this;

    // Convert each metadata object to a Map
    return metadataArray.map((metadata) => {
      const rowMap = new Map();

      // Set columns in the exact order specified in task
      rowMap.set("substance", self._getOrEmpty(metadata.getSubstance()));
      rowMap.set("equipment", self._getOrEmpty(metadata.getEquipment()));
      rowMap.set("application", self._getOrEmpty(metadata.getApplication()));
      rowMap.set("ghg", self._getOrEmpty(metadata.getGhg()));
      rowMap.set("hasDomestic", metadata.getHasDomestic().toString());
      rowMap.set("hasImport", metadata.getHasImport().toString());
      rowMap.set("hasExport", metadata.getHasExport().toString());
      rowMap.set("energy", self._getOrEmpty(metadata.getEnergy()));
      rowMap.set("initialChargeDomestic", self._getOrEmpty(metadata.getInitialChargeDomestic()));
      rowMap.set("initialChargeImport", self._getOrEmpty(metadata.getInitialChargeImport()));
      rowMap.set("initialChargeExport", self._getOrEmpty(metadata.getInitialChargeExport()));
      rowMap.set("retirement", self._getOrEmpty(metadata.getRetirement()));
      rowMap.set(
        "retirementWithReplacement",
        self._getOrEmpty(metadata.getRetirementWithReplacement()),
      );
      rowMap.set("defaultSales", self._getOrEmpty(metadata.getDefaultSales()));
      rowMap.set("key", self._getOrEmpty(metadata.getKey()));

      return rowMap;
    });
  }

  /**
   * Create data URI with CSV content for download.
   *
   * This method serializes the metadata array to CSV format and wraps it
   * in a data URI that can be used for browser downloads. The CSV format
   * includes proper header row and RFC 4180 compliant escaping.
   *
   * @param {SubstanceMetadata[]} metadataArray - Array of metadata objects
   * @returns {string} Data URI string (data:text/csv;charset=utf-8,...)
   */
  renderMetaToCsvUri(metadataArray) {
    const self = this;

    const serializedMaps = self.serialize(metadataArray);
    const csvContent = self._generateCsvString(serializedMaps);
    return "data:text/csv;charset=utf-8," + encodeURIComponent(csvContent);
  }

  /**
   * Generate CSV string from array of Maps.
   *
   * Creates a complete CSV string with header row and data rows,
   * properly escaping values that contain special CSV characters.
   *
   * @param {Array.<Map.<string, string>>} serializedMaps - Array of row Maps
   * @returns {string} Complete CSV content string
   * @private
   */
  _generateCsvString(serializedMaps) {
    const self = this;

    // Generate all rows using flatMap and unshift for header
    const csvRows = [
      META_COLUMNS.join(","),
      ...serializedMaps.flatMap((rowMap) => {
        const rowValues = META_COLUMNS.map((column) => {
          const value = rowMap.get(column) || "";
          return self._escapeCsvValue(value);
        });
        return rowValues.join(",");
      }),
    ];

    return csvRows.join("\n");
  }

  /**
   * Escape a CSV value according to RFC 4180 specification.
   *
   * RFC 4180 requires that fields containing commas, quotes, or newlines must be
   * enclosed in double quotes. Additionally, any double quotes within the field
   * must be escaped by doubling them (e.g., " becomes "").
   *
   * @param {string} value - The value to escape
   * @returns {string} Escaped CSV value
   * @private
   */
  _escapeCsvValue(value) {
    const self = this;

    if (!value) {
      return "";
    }

    const stringValue = String(value);

    // Check if escaping is needed (contains comma, quote, or newline)
    if (stringValue.includes(",") || stringValue.includes('"') || stringValue.includes("\n")) {
      // Escape quotes by doubling them
      const escaped = stringValue.replace(/"/g, '""');
      // Wrap in quotes
      return '"' + escaped + '"';
    }

    return stringValue;
  }

  /**
   * Convert array of Maps to structured result with SubstanceMetadata for CSV import.
   *
   * Each Map represents a CSV row with column names as keys and string values.
   * Boolean fields are converted from string representation to boolean values.
   * Empty or missing values are handled gracefully with defaults. Parsing errors
   * are collected and returned in the result rather than throwing exceptions.
   *
   * @param {Array.<Map.<string, string>>} arrayOfMaps - Array of Maps with CSV data
   * @returns {SubstanceMetadataParseResult} Result containing parsed metadata and errors
   */
  deserialize(arrayOfMaps) {
    const self = this;
    const result = new SubstanceMetadataParseResult();

    if (!Array.isArray(arrayOfMaps)) {
      result.addError(
        new SubstanceMetadataError(0, "input", "Input must be an array", "SYSTEM"),
      );
      return result;
    }

    for (let i = 0; i < arrayOfMaps.length; i++) {
      const rowMap = arrayOfMaps[i];
      const rowNumber = i + 1; // 1-based for user display

      try {
        if (!(rowMap instanceof Map)) {
          result.addError(
            new SubstanceMetadataError(
              rowNumber,
              "input",
              "Row data must be a Map object",
              "SYSTEM",
            ),
          );
          continue;
        }

        const metadata = self._parseRowToMetadata(rowMap, rowNumber, result);
        if (metadata) {
          result.addUpdate(new SubstanceMetadataUpdate("", metadata));
        }
      } catch (systemError) {
        // Unexpected system errors that shouldn't happen in normal operation
        result.addError(new SubstanceMetadataError(rowNumber, "system",
          `Unexpected error: ${systemError.message}`, "SYSTEM"));
      }
    }

    return result;
  }

  /**
   * Convert CSV string to structured result with SubstanceMetadataUpdate for import.
   *
   * Uses Papa Parse to parse CSV string with headers into objects,
   * then converts to SubstanceMetadataUpdate instances. The CSV key column
   * is used as the oldName to identify existing substances for updates.
   * Parsing errors are collected and returned in the result rather than throwing exceptions.
   *
   * @param {string} csvString - CSV content string with headers
   * @returns {SubstanceMetadataParseResult} Result containing parsed updates and errors
   */
  deserializeMetaFromCsvString(csvString) {
    const self = this;
    const result = new SubstanceMetadataParseResult();

    if (typeof csvString !== "string") {
      result.addError(new SubstanceMetadataError(0, "input", "CSV input must be a string", "USER"));
      return result;
    }

    if (!csvString.trim()) {
      return result; // Empty string is valid, just return empty result
    }

    // Parse CSV using Papa Parse
    const parseResult = Papa.parse(csvString, {
      header: true,
      dynamicTyping: false,
      skipEmptyLines: true,
    });

    // Check for Papa Parse errors (user data issues)
    if (parseResult.errors && parseResult.errors.length > 0) {
      for (const error of parseResult.errors) {
        const rowNum = error.row ? error.row + 1 : 0; // Papa Parse uses 0-based rows
        result.addError(new SubstanceMetadataError(rowNum, "parsing", error.message, "USER"));
      }
    }

    // Validate required columns exist
    if (parseResult.data.length > 0) {
      const firstRow = parseResult.data[0];
      const missingColumns = META_COLUMNS.filter((col) => !(col in firstRow));
      if (missingColumns.length > 0) {
        result.addError(new SubstanceMetadataError(0, "columns",
          `Missing required columns: ${missingColumns.join(", ")}`, "USER"));
        return result; // Can't continue without required columns
      }
    }

    // Process each data row
    for (let i = 0; i < parseResult.data.length; i++) {
      const rowData = parseResult.data[i];
      // +2 because Papa Parse excludes header, and we want 1-based counting
      const rowNumber = i + 2;

      try {
        const oldName = self._getOrEmpty(rowData["key"]);

        // Validate key format if present
        if (oldName && !self._isValidKeyFormat(oldName)) {
          result.addError(new SubstanceMetadataError(
            rowNumber,
            "key",
            `Invalid key format. Expected: "substance" for "application", got: ${oldName}`,
            "USER",
          ));
        }

        const rowMap = self._createRowMapFromCsvData(rowData, rowNumber, result);

        // A valid rowMap will be returned or, if not, the error will have been
        // added to result for reporting and should be ignored.
        if (rowMap) {
          const metadataResult = self._parseRowToMetadata(rowMap, rowNumber, result);
          if (metadataResult) {
            result.addUpdate(new SubstanceMetadataUpdate(oldName, metadataResult));
          }
        }
      } catch (systemError) {
        result.addError(new SubstanceMetadataError(rowNumber, "system",
          `Unexpected error processing row: ${systemError.message}`, "SYSTEM"));
      }
    }

    return result;
  }

  /**
   * Parse a string value to boolean using BOOLEAN_VALUES map.
   *
   * Handles various string representations and returns FieldParseResult
   * with either the parsed value or an error.
   *
   * @param {string} value - String value to parse as boolean
   * @returns {FieldParseResult} Result containing parsed boolean or error
   * @private
   */
  _parseBoolean(value) {
    const self = this;

    if (!value || typeof value !== "string") {
      return FieldParseResult.success(false);
    }

    const trimmed = value.trim().toLowerCase();

    if (BOOLEAN_VALUES.has(trimmed)) {
      return FieldParseResult.success(BOOLEAN_VALUES.get(trimmed));
    }

    const validValues = Array.from(BOOLEAN_VALUES.keys()).join(", ");
    const error = new Error(
      `Invalid boolean value: ${value}. Expected one of: ${validValues}`,
    );
    return FieldParseResult.failure(error);
  }

  /**
   * Normalize a defaultSales CSV value to internal assumeMode representation.
   *
   * Accepts both user-facing values (e.g., "continue from prior year") and internal
   * values (e.g., "continued") for flexibility and backward compatibility. Empty or
   * whitespace-only values are treated as "continued" (the default behavior).
   *
   * @param {string} value - The defaultSales value from CSV
   * @returns {FieldParseResult} Result containing normalized value or error
   * @private
   */
  _normalizeDefaultSales(value) {
    const self = this;

    // Handle empty/null/undefined values - treat as default (continued)
    if (!value || typeof value !== "string") {
      return FieldParseResult.success("continued");
    }

    // Trim and check if empty after trimming
    const trimmed = value.trim().toLowerCase();
    if (!trimmed) {
      return FieldParseResult.success("continued");
    }

    // Check if the value exists in our mapping (case-insensitive)
    for (const [key, internalValue] of DEFAULT_SALES_TO_ASSUME_MODE.entries()) {
      if (key.toLowerCase() === trimmed) {
        return FieldParseResult.success(internalValue);
      }
    }

    // Value not found - return error with helpful message
    const validValues = VALID_DEFAULT_SALES_VALUES.map((v) => `"${v}"`).join(", ");
    const error = new Error(`Invalid value "${value}". Expected one of: ${validValues}`);
    return FieldParseResult.failure(error);
  }

  /**
   * Validate that a key field follows the expected format.
   *
   * The expected format is: "substance name" for "application name"
   * Empty keys are considered valid as they indicate new substances.
   *
   * @param {string} key - The key field value to validate
   * @returns {boolean} True if the key format is valid or empty
   * @private
   */
  _isValidKeyFormat(key) {
    const self = this;

    if (!key) return true; // Empty keys are allowed for new substances

    // Check for proper format: "substance" for "application"
    const match = key.match(/^"([^"]+)"\s+for\s+"([^"]+)"$/);
    return match !== null;
  }

  /**
   * Parse a single row Map to SubstanceMetadata object with error handling.
   *
   * Validates and parses boolean fields, collects any parsing errors, and builds
   * a SubstanceMetadata object using default values for invalid fields.
   *
   * @param {Map<string, string>} rowMap - Map containing row data
   * @param {number} rowNumber - Row number for error reporting
   * @param {SubstanceMetadataParseResult} result - Result object to collect errors
   * @returns {SubstanceMetadata|null} Parsed metadata or null if critical errors
   * @private
   */
  _parseRowToMetadata(rowMap, rowNumber, result) {
    const self = this;
    let hasErrors = false;

    // Validate and parse boolean fields
    const booleanFields = ["hasDomestic", "hasImport", "hasExport"];
    const parsedBooleans = {};

    for (const field of booleanFields) {
      const parseResult = self._parseBoolean(rowMap.get(field));
      if (parseResult.hasError()) {
        result.addError(
          new SubstanceMetadataError(rowNumber, field, parseResult.getError().message, "USER"),
        );
        parsedBooleans[field] = false; // Default value
        hasErrors = true;
      } else {
        parsedBooleans[field] = parseResult.getResult();
      }
    }

    // Parse retirementWithReplacement (Component 5)
    const retirementWithReplacementRaw = self._getOrEmpty(rowMap.get("retirementWithReplacement"));
    let retirementWithReplacement = false; // Default to false (normal retirement)

    if (retirementWithReplacementRaw && retirementWithReplacementRaw.trim()) {
      const parseResult = self._parseBoolean(retirementWithReplacementRaw);
      if (parseResult.hasError()) {
        result.addError(
          new SubstanceMetadataError(
            rowNumber,
            "retirementWithReplacement",
            "Invalid value \"" + retirementWithReplacementRaw + "\". " +
              "Expected one of: \"true\", \"false\", \"yes\", \"no\", \"1\", \"0\"",
            "USER",
          ),
        );
        hasErrors = true;
      } else {
        retirementWithReplacement = parseResult.getResult();
      }
    }

    // Parse and normalize defaultSales value (Component 2)
    const defaultSalesRaw = self._getOrEmpty(rowMap.get("defaultSales"));
    let defaultSalesNormalized = "continued"; // Default value

    const defaultSalesResult = self._normalizeDefaultSales(defaultSalesRaw);
    if (defaultSalesResult.hasError()) {
      result.addError(
        new SubstanceMetadataError(
          rowNumber,
          "defaultSales",
          defaultSalesResult.getError().message,
          "USER",
        ),
      );
      // Use default value on error to allow partial processing
      defaultSalesNormalized = "continued";
      hasErrors = true;
    } else {
      defaultSalesNormalized = defaultSalesResult.getResult();
    }

    // Build metadata object with error handling
    const builder = new SubstanceMetadataBuilder();
    builder
      .setSubstance(self._getOrEmpty(rowMap.get("substance")))
      .setEquipment(self._getOrEmpty(rowMap.get("equipment")))
      .setApplication(self._getOrEmpty(rowMap.get("application")))
      .setGhg(self._getOrEmpty(rowMap.get("ghg")))
      .setHasDomestic(parsedBooleans.hasDomestic)
      .setHasImport(parsedBooleans.hasImport)
      .setHasExport(parsedBooleans.hasExport)
      .setEnergy(self._getOrEmpty(rowMap.get("energy")))
      .setInitialChargeDomestic(self._getOrEmpty(rowMap.get("initialChargeDomestic")))
      .setInitialChargeImport(self._getOrEmpty(rowMap.get("initialChargeImport")))
      .setInitialChargeExport(self._getOrEmpty(rowMap.get("initialChargeExport")))
      .setRetirement(self._getOrEmpty(rowMap.get("retirement")))
      .setRetirementWithReplacement(retirementWithReplacement ? "true" : "false")
      .setDefaultSales(defaultSalesNormalized);

    return builder.build();
  }

  /**
   * Create a row Map from CSV data object for processing.
   *
   * Filters out the key column and creates a Map with metadata columns only.
   * Reports any missing column data as user errors.
   *
   * @param {Object} rowData - Parsed CSV row data object
   * @param {number} rowNumber - Row number for error reporting
   * @param {SubstanceMetadataParseResult} result - Result object to collect errors
   * @returns {Map<string, string>|null} Row Map or null if critical errors
   * @private
   */
  _createRowMapFromCsvData(rowData, rowNumber, result) {
    const self = this;
    const rowMap = new Map();
    const metadataColumns = META_COLUMNS.filter((col) => col !== "key");

    for (const column of metadataColumns) {
      if (column in rowData) {
        rowMap.set(column, self._getOrEmpty(rowData[column]));
      } else {
        // Column missing - this would be caught earlier in header validation
        result.addError(new SubstanceMetadataError(rowNumber, column,
          "Missing column data", "USER"));
      }
    }

    return rowMap;
  }
}

/**
 * Result container for metadata validation operations.
 *
 * Tracks validation state and collects validation errors for reporting.
 * Provides methods to check success state and combine multiple validation results.
 */
class ValidationResult {
  /**
   * Create a new ValidationResult.
   *
   * @param {boolean} isValid - Whether validation passed
   * @param {string[]} errors - Array of validation error messages
   */
  constructor(isValid = true, errors = []) {
    const self = this;
    self._isValid = isValid;
    self._errors = errors;
  }

  /**
   * Check if validation was successful.
   *
   * @returns {boolean} True if validation passed
   */
  isValid() {
    const self = this;
    return self._isValid;
  }

  /**
   * Get all validation error messages.
   *
   * @returns {string[]} Array of error messages
   */
  getErrors() {
    const self = this;
    return self._errors;
  }

  /**
   * Check if validation failed.
   *
   * @returns {boolean} True if validation failed
   */
  hasErrors() {
    const self = this;
    return !self._isValid;
  }

  /**
   * Create a successful validation result.
   *
   * @returns {ValidationResult} Success result
   */
  static success() {
    return new ValidationResult(true, []);
  }

  /**
   * Create a failed validation result.
   *
   * @param {string[]} errors - Array of error messages
   * @returns {ValidationResult} Failure result
   */
  static failure(errors) {
    return new ValidationResult(false, errors);
  }

  /**
   * Add an error message to this result.
   *
   * @param {string} message - Error message to add
   */
  addError(message) {
    const self = this;
    self._errors.push(message);
    self._isValid = false;
  }

  /**
   * Combine this result with another validation result.
   *
   * @param {ValidationResult} other - Other result to combine
   * @returns {ValidationResult} Combined result
   */
  combine(other) {
    const self = this;
    const combinedErrors = [...self._errors, ...other.getErrors()];
    const combinedValid = self._isValid && other.isValid();
    return new ValidationResult(combinedValid, combinedErrors);
  }

  /**
   * Get formatted error summary.
   *
   * @returns {string} Formatted error messages joined by newlines
   */
  getErrorSummary() {
    const self = this;
    return self._errors.join("\n");
  }
}

/**
 * Validator for SubstanceMetadata objects.
 *
 * Provides comprehensive validation for required fields and optional field formats.
 * Validates individual metadata objects and batches of metadata objects.
 */
class MetadataValidator {
  /**
   * Create a new MetadataValidator.
   */
  constructor() {
    const self = this;

    // Define required fields and their validation rules
    self._requiredFields = new Map([
      ["substance", self._validateSubstanceName.bind(self)],
      ["application", self._validateApplicationName.bind(self)],
    ]);

    // Define optional field validators
    self._optionalFieldValidators = new Map([
      ["ghg", self._validateGhgValue.bind(self)],
      ["energy", self._validateEnergyValue.bind(self)],
      ["retirement", self._validateRetirementValue.bind(self)],
      ["initialChargeDomestic", self._validateChargeValue.bind(self)],
      ["initialChargeImport", self._validateChargeValue.bind(self)],
      ["initialChargeExport", self._validateChargeValue.bind(self)],
    ]);
  }

  /**
   * Validate a single SubstanceMetadata object.
   *
   * @param {SubstanceMetadata} metadata - Metadata object to validate
   * @param {number} index - Index of this metadata in a batch (for error reporting)
   * @returns {ValidationResult} Validation result
   */
  validateSingle(metadata, index = 0) {
    const self = this;
    const result = new ValidationResult();

    // Validate required fields
    for (const [fieldName, validator] of self._requiredFields) {
      const fieldValue = self._getFieldValue(metadata, fieldName);
      const fieldResult = validator(fieldValue, fieldName, index);
      if (!fieldResult.isValid()) {
        result.addError(`Row ${index + 1}: ${fieldResult.getErrorSummary()}`);
      }
    }

    // Validate optional fields if they have values
    for (const [fieldName, validator] of self._optionalFieldValidators) {
      const fieldValue = self._getFieldValue(metadata, fieldName);
      if (fieldValue && fieldValue.trim()) {
        const fieldResult = validator(fieldValue, fieldName, index);
        if (!fieldResult.isValid()) {
          result.addError(`Row ${index + 1}: ${fieldResult.getErrorSummary()}`);
        }
      }
    }

    return result;
  }

  /**
   * Validate a batch of SubstanceMetadata objects.
   *
   * @param {SubstanceMetadata[]} metadataArray - Array of metadata objects to validate
   * @returns {ValidationResult} Overall validation result
   */
  validateBatch(metadataArray) {
    const self = this;
    let overallResult = ValidationResult.success();

    // Validate each metadata object individually
    for (let i = 0; i < metadataArray.length; i++) {
      const singleResult = self.validateSingle(metadataArray[i], i);
      overallResult = overallResult.combine(singleResult);
    }

    // Check for duplicate substance names within batch
    const duplicateResult = self._validateNoDuplicateNames(metadataArray);
    overallResult = overallResult.combine(duplicateResult);

    return overallResult;
  }

  /**
   * Get field value from metadata object using appropriate getter method.
   *
   * @param {SubstanceMetadata} metadata - Metadata object
   * @param {string} fieldName - Name of field to get
   * @returns {string} Field value or empty string
   * @private
   */
  _getFieldValue(metadata, fieldName) {
    const self = this;
    const getterMap = {
      "substance": () => metadata.getSubstance(),
      "application": () => metadata.getApplication(),
      "ghg": () => metadata.getGhg(),
      "energy": () => metadata.getEnergy(),
      "retirement": () => metadata.getRetirement(),
      "initialChargeDomestic": () => metadata.getInitialChargeDomestic(),
      "initialChargeImport": () => metadata.getInitialChargeImport(),
      "initialChargeExport": () => metadata.getInitialChargeExport(),
    };

    const getter = getterMap[fieldName];
    return getter ? getter() : "";
  }

  /**
   * Validate substance name field.
   *
   * @param {string} value - Field value
   * @param {string} fieldName - Field name
   * @param {number} index - Row index
   * @returns {ValidationResult} Validation result
   * @private
   */
  _validateSubstanceName(value, fieldName, index) {
    const self = this;

    if (!value || !value.trim()) {
      return ValidationResult.failure(["Substance name is required"]);
    }

    if (value.trim().length < 2) {
      return ValidationResult.failure(["Substance name must be at least 2 characters long"]);
    }

    // Check for invalid characters based on QubecTalk grammar
    if (value.includes("\"") || value.includes("\n") || value.includes("\r")) {
      return ValidationResult.failure(["Substance name cannot contain quotes or newlines"]);
    }

    return ValidationResult.success();
  }

  /**
   * Validate application name field.
   *
   * @param {string} value - Field value
   * @param {string} fieldName - Field name
   * @param {number} index - Row index
   * @returns {ValidationResult} Validation result
   * @private
   */
  _validateApplicationName(value, fieldName, index) {
    const self = this;

    if (!value || !value.trim()) {
      return ValidationResult.failure(["Application name is required"]);
    }

    if (value.trim().length < 2) {
      return ValidationResult.failure(["Application name must be at least 2 characters long"]);
    }

    return ValidationResult.success();
  }

  /**
   * Validate GHG value format.
   *
   * @param {string} value - Field value
   * @param {string} fieldName - Field name
   * @param {number} index - Row index
   * @returns {ValidationResult} Validation result
   * @private
   */
  _validateGhgValue(value, fieldName, index) {
    const self = this;

    if (!parseUnitValue(value)) {
      return ValidationResult.failure([
        "GHG value must be in format \"number unit\" (e.g., \"1430 kgCO2e / kg\")",
      ]);
    }
    return ValidationResult.success();
  }

  /**
   * Validate energy value format.
   *
   * @param {string} value - Field value
   * @param {string} fieldName - Field name
   * @param {number} index - Row index
   * @returns {ValidationResult} Validation result
   * @private
   */
  _validateEnergyValue(value, fieldName, index) {
    const self = this;

    if (!parseUnitValue(value)) {
      return ValidationResult.failure([
        "Energy value must be in format \"number unit\" (e.g., \"500 kwh / unit\")",
      ]);
    }
    return ValidationResult.success();
  }

  /**
   * Validate retirement value format.
   *
   * @param {string} value - Field value
   * @param {string} fieldName - Field name
   * @param {number} index - Row index
   * @returns {ValidationResult} Validation result
   * @private
   */
  _validateRetirementValue(value, fieldName, index) {
    const self = this;

    if (!parseUnitValue(value)) {
      return ValidationResult.failure([
        "Retirement value must be in format \"number unit\" (e.g., \"10% / year\")",
      ]);
    }
    return ValidationResult.success();
  }

  /**
   * Validate charge value format.
   *
   * @param {string} value - Field value
   * @param {string} fieldName - Field name
   * @param {number} index - Row index
   * @returns {ValidationResult} Validation result
   * @private
   */
  _validateChargeValue(value, fieldName, index) {
    const self = this;

    if (!parseUnitValue(value)) {
      return ValidationResult.failure([
        "Charge value must be in format \"number unit\" (e.g., \"0.15 kg / unit\")",
      ]);
    }
    return ValidationResult.success();
  }

  /**
   * Validate no duplicate names in metadata batch.
   *
   * @param {SubstanceMetadata[]} metadataArray - Array of metadata objects
   * @returns {ValidationResult} Validation result
   * @private
   */
  _validateNoDuplicateNames(metadataArray) {
    const self = this;
    const keys = new Map(); // key -> first occurrence index
    const errors = [];

    for (let i = 0; i < metadataArray.length; i++) {
      const metadata = metadataArray[i];
      const key = metadata.getKey(); // Use getKey() instead of getName()

      if (keys.has(key)) {
        const firstIndex = keys.get(key);
        errors.push(
          `Duplicate substance key "${key}" found at rows ${firstIndex + 1} and ${i + 1}`,
        );
      } else {
        keys.set(key, i);
      }
    }

    return errors.length > 0 ? ValidationResult.failure(errors) : ValidationResult.success();
  }
}

/**
 * Custom error class for validation failures.
 *
 * Extends the standard Error class with validation-specific functionality.
 * Always indicates user-correctable errors rather than system errors.
 */
class ValidationError extends Error {
  /**
   * Create a new ValidationError.
   *
   * @param {string} message - Error message
   * @param {string[]} validationErrors - Array of detailed validation error messages
   */
  constructor(message, validationErrors = []) {
    super(message);
    const self = this;
    self.name = "ValidationError";
    self._validationErrors = validationErrors;
  }

  /**
   * Get detailed validation error messages.
   *
   * @returns {string[]} Array of validation error messages
   */
  getValidationErrors() {
    const self = this;
    return self._validationErrors;
  }

  /**
   * Check if this is a user-correctable error.
   *
   * @returns {boolean} Always true for validation errors
   */
  isUserError() {
    return true; // Validation errors are always user-fixable
  }
}

/**
 * Applier for inserting substances from metadata into a Program.
 *
 * This class handles the insertion of new substances into a Program based on
 * SubstanceMetadata objects. It ensures applications exist, creates substances
 * with metadata-derived commands, and handles the integration with existing
 * program structure. Name conflicts are ignored for now (Component 6 scope).
 */
class MetaChangeApplier {
  /**
   * Create a new MetaChangeApplier instance.
   *
   * @param {Program} program - The Program instance to modify in-place
   */
  constructor(program) {
    const self = this;
    self._program = program;
    self._validator = new MetadataValidator(); // Add validator
  }

  /**
   * Enhanced validation method with detailed field checking.
   * Validates all updates comprehensively before any processing begins.
   *
   * @param {SubstanceMetadataUpdate[]} updateArray - Array of updates to validate
   * @throws {ValidationError} If any validation fails with detailed field information
   * @private
   */
  _validateUpdatesComprehensive(updateArray) {
    const self = this;
    const allErrors = [];

    for (let i = 0; i < updateArray.length; i++) {
      const update = updateArray[i];
      const metadata = update.getNewMetadata();

      // Get field-specific validation errors
      const fieldErrors = self._validateRequiredFields(metadata, i);
      allErrors.push(...fieldErrors);
    }

    if (allErrors.length > 0) {
      const errorMessage = "Validation failed for metadata updates. " +
        "Please correct the following issues:\n" + allErrors.join("\n");
      throw new ValidationError(errorMessage, allErrors);
    }
  }

  /**
   * Check if a metadata object has all required fields populated.
   *
   * @param {SubstanceMetadata} metadata - Metadata to validate
   * @param {number} index - Index in batch for error reporting
   * @returns {string[]} Array of validation error messages (empty if valid)
   * @private
   */
  _validateRequiredFields(metadata, index) {
    const self = this;
    const errors = [];
    const rowNumber = index + 1;

    // Check substance name
    const substanceName = metadata.getSubstance();
    if (!substanceName || !substanceName.trim()) {
      errors.push(`Row ${rowNumber}: Substance name is required and cannot be empty`);
    } else if (substanceName.trim().length < 2) {
      errors.push(`Row ${rowNumber}: Substance name must be at least 2 characters long`);
    }

    // Check application name
    const applicationName = metadata.getApplication();
    if (!applicationName || !applicationName.trim()) {
      errors.push(`Row ${rowNumber}: Application name is required and cannot be empty`);
    } else if (applicationName.trim().length < 2) {
      errors.push(`Row ${rowNumber}: Application name must be at least 2 characters long`);
    }

    return errors;
  }

  /**
   * Insert or update substances from metadata update array into the program.
   *
   * This method processes an array of SubstanceMetadataUpdate objects, ensures
   * required applications exist, and either updates existing substances or
   * creates new ones based on the oldName field. If oldName matches an existing
   * substance, it will be updated; otherwise a new substance is created.
   *
   * All metadata is validated before any changes are applied to prevent partial updates.
   *
   * @param {SubstanceMetadataUpdate[]} updateArray - Array of metadata update objects
   * @returns {Program} The modified program instance (for chaining)
   * @throws {ValidationError} If validation fails for any metadata
   * @throws {Error} If input is invalid
   */
  upsertMetadata(updateArray) {
    const self = this;

    // Input validation
    if (!Array.isArray(updateArray)) {
      throw new Error("Input must be an array of SubstanceMetadataUpdate objects");
    }

    if (updateArray.length === 0) {
      return self._program; // No work to do
    }

    // Validate instance types
    for (const update of updateArray) {
      if (!(update instanceof SubstanceMetadataUpdate)) {
        throw new Error("All items must be SubstanceMetadataUpdate instances");
      }
    }

    // Enhanced comprehensive validation - checks required fields upfront
    self._validateUpdatesComprehensive(updateArray);

    // Extract metadata for additional validation
    const metadataArray = updateArray.map((update) => update.getNewMetadata());

    // Pre-validate all metadata before making any changes (optional field validation)
    const validationResult = self._validator.validateBatch(metadataArray);

    if (!validationResult.isValid()) {
      const errorMessage =
        "Validation failed for metadata updates:\n" + validationResult.getErrorSummary();
      throw new ValidationError(errorMessage, validationResult.getErrors());
    }

    // All validation passed - proceed with updates
    for (const update of updateArray) {
      self._upsertMetadataSingle(update);
    }

    return self._program;
  }

  /**
   * Process a single metadata update (insert or update).
   *
   * @param {SubstanceMetadataUpdate} update - Single update to process
   * @private
   */
  _upsertMetadataSingle(update) {
    const self = this;
    const oldName = update.getOldName();
    const newMetadata = update.getNewMetadata();
    const oldNameNotEmpty = oldName && oldName.trim();

    self._ensureApplicationExists(newMetadata.getApplication());

    const existingSubstance = self._getSubstanceByKey(oldName, newMetadata.getApplication());

    if (existingSubstance) {
      self._updateMetadataSingle(newMetadata, existingSubstance);
    } else {
      if (oldNameNotEmpty) {
        console.warn(
          `No existing substance found for key "${oldName}". ` +
          "Creating new substance instead.",
        );
      }
      self._insertMetadataSingle(newMetadata);
    }
  }

  /**
   * Update an existing substance with new metadata.
   *
   * @param {SubstanceMetadata} metadata - New metadata to apply
   * @param {Substance} existingSubstance - Existing substance to update
   * @private
   */
  _updateMetadataSingle(metadata, existingSubstance) {
    const self = this;
    existingSubstance.updateMetadata(metadata, metadata.getApplication());
  }

  /**
   * Insert a new substance from metadata.
   *
   * @param {SubstanceMetadata} metadata - Metadata for new substance
   * @private
   */
  _insertMetadataSingle(metadata) {
    const self = this;
    const newName = metadata.getName();
    const application = self._program.getApplication(metadata.getApplication());
    const conflictingSubstance = application.getSubstances()
      .find((s) => s.getName() === newName);

    if (conflictingSubstance) {
      console.warn(`Substance "${newName}" already exists. Skipping insertion.`);
      return;
    }

    const substance = self._createSubstanceFromMetadata(metadata);
    self._addSubstanceToApplication(substance, metadata.getApplication());
  }

  /**
   * Parse a CSV key field to extract substance name and application.
   * Format: "substance name" for "application name"
   * @param {string} key - The key field value
   * @returns {{substanceName: string, applicationName: string}|null} Parsed values or null
   * @private
   */
  _parseKeyField(key) {
    if (!key || typeof key !== "string") return null;
    const match = key.match(/^"([^"]+)"\s+for\s+"([^"]+)"$/);
    if (!match) return null;
    return {
      substanceName: match[1],
      applicationName: match[2],
    };
  }

  /**
   * Get a substance by key field or name.
   * Tries to parse the key as a CSV key field first, then falls back to treating it as a name.
   *
   * @param {string} key - The key or name to search for
   * @param {string} defaultApplication - Default application if key doesn't specify one
   * @returns {Substance|null} Found substance or null
   * @private
   */
  _getSubstanceByKey(key, defaultApplication) {
    const self = this;
    if (!key || !key.trim()) return null;

    const parsed = self._parseKeyField(key);
    if (parsed) {
      return self._getSubstanceByName(parsed.substanceName, parsed.applicationName);
    } else {
      return self._getSubstanceByName(key, defaultApplication);
    }
  }

  /**
   * Check if a substance exists by name in an application.
   *
   * @param {string} substanceName - Name to search for
   * @param {string} applicationName - Application to search in
   * @returns {Substance|null} Found substance or null
   * @private
   */
  _getSubstanceByName(substanceName, applicationName) {
    const self = this;
    const application = self._program.getApplication(applicationName);
    if (!application) return null;

    return application.getSubstances()
      .find((s) => s.getName() === substanceName.trim()) || null;
  }

  /**
   * Ensure an application exists in the program, creating it if missing.
   *
   * @param {string} applicationName - Name of the application to ensure exists
   * @private
   */
  _ensureApplicationExists(applicationName) {
    const self = this;
    const substances = [];
    const isModification = false;
    const isCompatible = true;

    const existingApp = self._program.getApplication(applicationName);
    if (existingApp !== null) {
      return;
    }

    const newApplication = new Application(
      applicationName,
      substances,
      isModification,
      isCompatible,
    );

    self._program.addApplication(newApplication);
  }

  /**
   * Create a Substance object from SubstanceMetadata.
   *
   * This method converts metadata fields to appropriate Command objects
   * and uses SubstanceBuilder to create the substance definition.
   *
   * @param {SubstanceMetadata} metadata - Metadata to convert to substance
   * @returns {Substance} Created substance object
   * @private
   */
  _createSubstanceFromMetadata(metadata) {
    const self = this;

    const substanceName = metadata.getName();
    const builder = new SubstanceBuilder(substanceName, false); // Not a modification

    // Use the enhanced _parseUnitValue method for consistent and robust parsing

    // Add GHG equals command if present
    const ghgValue = self._parseUnitValue(metadata.getGhg());
    if (ghgValue) {
      // For GHG commands, we need to ensure the routing works correctly by using specific target
      builder.setEqualsGhg(new Command("equals", null, ghgValue, null));
    }

    // Add energy equals command if present
    const energyValue = self._parseUnitValue(metadata.getEnergy());
    if (energyValue) {
      // For energy commands, we need to ensure the routing works correctly by using specific target
      builder.setEqualsKwh(new Command("equals", null, energyValue, null));
    }

    // Add enable commands based on stream flags
    if (metadata.getHasDomestic()) {
      builder.addCommand(new Command("enable", "domestic", null, null));
    }
    if (metadata.getHasImport()) {
      builder.addCommand(new Command("enable", "import", null, null));
    }
    if (metadata.getHasExport()) {
      builder.addCommand(new Command("enable", "export", null, null));
    }

    // Add initial charge commands for each stream (skip zero values)
    const domesticCharge = self._parseUnitValue(metadata.getInitialChargeDomestic());
    if (domesticCharge && domesticCharge.getValue() > 0) {
      const cmd = new Command("initial charge", "domestic", domesticCharge, null);
      builder.addCommand(cmd);
    }

    const importCharge = self._parseUnitValue(metadata.getInitialChargeImport());
    if (importCharge && importCharge.getValue() > 0) {
      builder.addCommand(new Command("initial charge", "import", importCharge, null));
    }

    const exportCharge = self._parseUnitValue(metadata.getInitialChargeExport());
    if (exportCharge && exportCharge.getValue() > 0) {
      builder.addCommand(new Command("initial charge", "export", exportCharge, null));
    }

    // Add retirement command if present (Component 5: includes withReplacement)
    const retirementValue = self._parseUnitValue(metadata.getRetirement());
    if (retirementValue) {
      // Parse withReplacement flag from metadata (stored as "true"/"false" string)
      const retirementWithReplacementStr = metadata.getRetirementWithReplacement();
      const withReplacement = retirementWithReplacementStr === "true";

      builder.addCommand(new RetireCommand(retirementValue, null, withReplacement));
    }

    // Set assumeMode from metadata defaultSales (Component 2)
    // Note: metadata.getDefaultSales() returns normalized internal value
    const defaultSales = metadata.getDefaultSales();
    if (defaultSales && defaultSales.trim()) {
      const assumeMode = defaultSales.trim();
      // Only set non-default values; "continued" is the default (null behavior)
      if (assumeMode !== "continued") {
        builder.setAssumeMode(assumeMode);
      }
    }

    // Build the substance (compatible with UI editing)
    return builder.build(true);
  }

  /**
   * Add a substance to an application.
   *
   * @param {Substance} substance - Substance to add
   * @param {string} applicationName - Name of application to add substance to
   * @private
   */
  _addSubstanceToApplication(substance, applicationName) {
    const self = this;

    const application = self._program.getApplication(applicationName);
    if (application === null) {
      throw new Error(`Application ${applicationName} not found`);
    }

    // Use insertSubstance method (null means no prior substance to replace)
    application.insertSubstance(null, substance);
  }

  /**
   * Check if value is the Infinity literal.
   *
   * Detects both positive and negative infinity strings before parsing.
   *
   * @param {string} cleanedValue - Value to check for infinity
   * @returns {boolean} True if value represents infinity
   * @private
   */
  _isInfinityLiteral(cleanedValue) {
    const self = this;
    return (
      cleanedValue.toLowerCase() === "infinity" ||
      cleanedValue.toLowerCase() === "-infinity"
    );
  }

  /**
   * Check for Infinity value in numeric parsing.
   *
   * Throws detailed error message if value is infinity.
   *
   * @param {string} valueStr - Original value string with formatting
   * @param {string} valueString - Full input string
   * @param {string} cleanedValue - Cleaned numeric value
   * @param {boolean} throwOnError - Whether to throw error
   * @returns {boolean} True if error detected
   * @throws {Error} If infinity detected and throwOnError is true
   * @private
   */
  _checkInfinity(valueStr, valueString, cleanedValue, throwOnError) {
    const self = this;
    if (self._isInfinityLiteral(cleanedValue)) {
      if (throwOnError) {
        throw new Error(
          `Invalid numeric value: "${valueStr}" in "${valueString}". ` +
          "Number must be finite (not infinite or NaN).",
        );
      }
      return true;
    }
    return false;
  }

  /**
   * Check for number with trailing spaces but no units.
   *
   * Detects when input is just a number without units portion.
   *
   * @param {string} trimmed - Trimmed input string
   * @param {string} valueString - Full input string
   * @param {boolean} throwOnError - Whether to throw error
   * @returns {boolean} True if error detected
   * @throws {Error} If pattern detected and throwOnError is true
   * @private
   */
  _checkNumberTrailingSpaces(trimmed, valueString, throwOnError) {
    const self = this;
    if (/^\d+\s*$/.test(trimmed)) {
      if (throwOnError) {
        throw new Error(
          `Invalid unit value format: "${valueString}". ` +
          "Units portion cannot be empty. " +
          "Example: \"1430 kgCO2e / kg\"",
        );
      }
      return true;
    }
    return false;
  }

  /**
   * Check for malformed decimal numbers.
   *
   * Detects multiple decimal points like "12.34.56".
   *
   * @param {string} trimmed - Trimmed input string
   * @param {string} valueString - Full input string
   * @param {boolean} throwOnError - Whether to throw error
   * @returns {boolean} True if error detected
   * @throws {Error} If pattern detected and throwOnError is true
   * @private
   */
  _checkMalformedDecimals(trimmed, valueString, throwOnError) {
    const self = this;
    if (/\d+\.\d+\.\d+/.test(trimmed)) {
      const parts = trimmed.split(/\s+/);
      if (parts.length >= 2) {
        if (throwOnError) {
          throw new Error(
            `Invalid numeric value: "${parts[0]}" in "${valueString}". ` +
            "Must be a valid number (integer or decimal). " +
            "Examples: \"1430\", \"10.5\", \"-42\", \"1,500.25\"",
          );
        }
        return true;
      }
    }
    return false;
  }

  /**
   * Check for missing space between number and units.
   *
   * Detects when no space exists between numeric and units portions.
   *
   * @param {string} trimmed - Trimmed input string
   * @param {string} valueString - Full input string
   * @param {boolean} throwOnError - Whether to throw error
   * @returns {boolean} True if error detected
   * @throws {Error} If pattern detected and throwOnError is true
   * @private
   */
  _checkMissingSpace(trimmed, valueString, throwOnError) {
    const self = this;
    if (!trimmed.includes(" ")) {
      if (throwOnError) {
        throw new Error(
          `Invalid unit value format: "${valueString}". ` +
          "Expected format: \"number units\" with a space between number and units. " +
          "Example: \"1430 kgCO2e / kg\"",
        );
      }
      return true;
    }
    return false;
  }

  /**
   * Check for non-numeric starting character.
   *
   * Detects when value doesn't start with a number or sign.
   *
   * @param {string} trimmed - Trimmed input string
   * @param {string} valueString - Full input string
   * @param {boolean} throwOnError - Whether to throw error
   * @returns {boolean} True if error detected
   * @throws {Error} If pattern detected and throwOnError is true
   * @private
   */
  _checkNonNumeric(trimmed, valueString, throwOnError) {
    const self = this;
    if (/^\s*[^\d+-]/.test(trimmed)) {
      if (throwOnError) {
        throw new Error(
          `Invalid unit value format: "${valueString}". ` +
          "Must start with a number (optionally signed). " +
          "Example: \"10% / year\" or \"-5.5 mt / year\"",
        );
      }
      return true;
    }
    return false;
  }

  /**
   * Throw generic invalid format error.
   *
   * Used when more specific error checks don't match.
   *
   * @param {string} valueString - Full input string
   * @throws {Error} Generic invalid format error
   * @private
   */
  _throwInvalidGeneric(valueString) {
    const self = this;
    throw new Error(
      `Invalid unit value format: "${valueString}". ` +
      "Expected format: \"number units\". " +
      "Examples: \"1430 kgCO2e / kg\", \"10% / year\", \"0.15 kg / unit\"",
    );
  }

  /**
   * Check if numeric value is not parseable by JavaScript.
   *
   * Validates that parseFloat result is finite and not NaN.
   *
   * @param {number} numericValue - Parsed numeric value
   * @param {string} valueStr - Original value string
   * @param {string} valueString - Full input string
   * @param {boolean} throwOnError - Whether to throw error
   * @returns {boolean} True if error detected
   * @throws {Error} If invalid numeric detected and throwOnError is true
   * @private
   */
  _checkJsUnparsable(numericValue, valueStr, valueString, throwOnError) {
    const self = this;
    if (isNaN(numericValue)) {
      if (throwOnError) {
        throw new Error(
          `Invalid numeric value: "${valueStr}" in "${valueString}". ` +
          "Must be a valid number (integer or decimal). " +
          "Examples: \"1430\", \"10.5\", \"-42\", \"1,500.25\"",
        );
      }
      return true;
    }

    if (!isFinite(numericValue)) {
      if (throwOnError) {
        throw new Error(
          `Invalid numeric value: "${valueStr}" in "${valueString}". ` +
          "Number must be finite (not infinite or NaN).",
        );
      }
      return true;
    }

    return false;
  }

  /**
   * Handle percentage sign in unit value.
   *
   * If original value contained %, includes it in final units string.
   *
   * @param {string} valueStr - Original value string with formatting
   * @param {string} unitsStr - Extracted units string
   * @returns {string} Final units string with percentage if applicable
   * @private
   */
  _handlePercentSignInUnitValue(valueStr, unitsStr) {
    const self = this;
    return valueStr.includes("%") ? `% ${unitsStr.trim()}` : unitsStr.trim();
  }

  /**
   * Parse units string with validation.
   *
   * Ensures units string is not empty after processing.
   *
   * @param {string} valueStr - Original value string with formatting
   * @param {string} unitsStr - Extracted units string
   * @param {string} valueString - Full input string
   * @param {boolean} throwOnError - Whether to throw error
   * @returns {string|null} Final units string or null if invalid
   * @throws {Error} If units invalid and throwOnError is true
   * @private
   */
  _parseUnitString(valueStr, unitsStr, valueString, throwOnError) {
    const self = this;
    const isEmpty = !unitsStr || !unitsStr.trim();

    if (isEmpty) {
      if (throwOnError) {
        throw new Error(
          `Invalid unit value format: "${valueString}". ` +
          "Units portion cannot be empty. " +
          "Example: \"1430 kgCO2e / kg\"",
        );
      }
      return null;
    }

    const finalUnits = self._handlePercentSignInUnitValue(valueStr, unitsStr);

    if (!finalUnits) {
      if (throwOnError) {
        throw new Error(
          `Invalid units: "${unitsStr}" in "${valueString}". ` +
          "Units cannot be empty after processing.",
        );
      }
      return null;
    }

    return finalUnits;
  }

  /**
   * Parse unit value strings with comprehensive validation and error handling.
   *
   * This method provides enhanced parsing capabilities for unit value strings
   * commonly used in substance metadata. It supports various numeric formats
   * including integers, decimals, negative numbers, comma separators, and
   * percentages. The method is designed to be more robust and maintainable
   * than the global parseUnitValue function.
   *
   * Supported formats include:
   * - "1430 kgCO2e / kg" (GHG values)
   * - "10% / year" (percentage values)
   * - "0.15 kg / unit" (charge values)
   * - "500 kwh / unit" (energy values)
   * - "-5.5 mt / year" (negative values)
   * - "1,500.25 units" (comma-separated values)
   *
   * @param {string} valueString - String containing numeric value and units
   *   Must be in format "number units" where number can include decimals,
   *   commas, signs, and optional percentage symbol
   * @param {boolean} throwOnError - Whether to throw errors for invalid formats.
   *   When false, returns null for invalid inputs. When true, throws detailed
   *   error messages for debugging and validation purposes.
   * @returns {EngineNumber|null} Parsed EngineNumber instance with numeric value
   *   and units string, or null if invalid/empty and throwOnError is false
   * @throws {Error} If throwOnError is true and parsing fails. Error messages
   *   include specific details about what was invalid for user feedback.
   * @private
   */
  _parseUnitValue(valueString, throwOnError = false) {
    const self = this;

    if (valueString === null || valueString === undefined) {
      if (throwOnError) {
        const actualType = valueString === null ? "null" : "undefined";
        throw new Error(`Value string must be a non-empty string, got: ${actualType}`);
      }
      return null;
    }

    if (typeof valueString !== "string") {
      if (throwOnError) {
        throw new Error(`Value string must be a non-empty string, got: ${typeof valueString}`);
      }
      return null;
    }

    const trimmed = valueString.trim();
    const isEmpty = !trimmed;

    if (isEmpty) {
      if (throwOnError) {
        throw new Error("Value string cannot be empty or whitespace-only");
      }
      return null;
    }

    if (/^(Infinity|-?Infinity)\s+/.test(trimmed)) {
      if (throwOnError) {
        throw new Error(
          `Invalid numeric value: "${trimmed.split(/\s+/)[0]}" in "${valueString}". ` +
          "Number must be finite (not infinite or NaN).",
        );
      }
      return null;
    }

    const match = trimmed.match(UNIT_VALUE_REGEX);

    if (!match) {
      if (throwOnError) {
        self._checkNumberTrailingSpaces(trimmed, valueString, true);
        self._checkMalformedDecimals(trimmed, valueString, true);
        self._checkMissingSpace(trimmed, valueString, true);
        self._checkNonNumeric(trimmed, valueString, true);
        self._throwInvalidGeneric(valueString);
      }
      return null;
    }

    const [, valueStr, unitsStr] = match;

    const finalUnits = self._parseUnitString(valueStr, unitsStr, valueString, throwOnError);
    if (finalUnits === null && throwOnError) {
      return null;
    }

    const cleanedValue = valueStr.replace(/[,%]/g, "");

    if (self._checkInfinity(valueStr, valueString, cleanedValue, throwOnError)) {
      return null;
    }

    const numericValue = parseFloat(cleanedValue);

    if (self._checkJsUnparsable(numericValue, valueStr, valueString, throwOnError)) {
      return null;
    }

    try {
      return new EngineNumber(numericValue, finalUnits, valueStr.trim());
    } catch (engineError) {
      if (throwOnError) {
        throw new Error(
          `Failed to create EngineNumber from "${valueString}": ${engineError.message}`,
        );
      }
      return null;
    }
  }
}

/**
 * Container for substance metadata updates with old and new information.
 *
 * Used to distinguish between inserting new substances and updating existing ones.
 * The oldName corresponds to the key column in CSV files and identifies which
 * existing substance should be updated.
 */
class SubstanceMetadataUpdate {
  /**
   * Create a new SubstanceMetadataUpdate instance.
   *
   * @param {string} oldName - The name of the existing substance to update (from CSV key column)
   * @param {SubstanceMetadata} newMetadata - The new metadata to apply
   */
  constructor(oldName, newMetadata) {
    const self = this;
    self._oldName = oldName || "";
    self._newMetadata = newMetadata;
  }

  /**
   * Get the old name (key) that identifies which existing substance to update.
   *
   * @returns {string} The old name from CSV key column
   */
  getOldName() {
    const self = this;
    return self._oldName;
  }

  /**
   * Get the new metadata to apply to the substance.
   *
   * @returns {SubstanceMetadata} The new metadata object
   */
  getNewMetadata() {
    const self = this;
    return self._newMetadata;
  }
}

/**
 * Parse unit value strings like "5 kgCO2e / kg" into EngineNumber instances.
 * Handles numeric values with optional commas, decimals, and percentage signs.
 *
 * @param {string} valueString - String containing numeric value and units
   *   (e.g., "1430 kgCO2e / kg", "10% / year")
 * @param {boolean} throwOnError - Whether to throw errors for invalid formats (default: false)
 * @returns {EngineNumber|null} Parsed EngineNumber instance, or null if invalid/empty
 */
function parseUnitValue(valueString, throwOnError = false) {
  if (!valueString || typeof valueString !== "string" || !valueString.trim()) {
    return null;
  }

  const trimmed = valueString.trim();

  if (throwOnError) {
    const firstSpaceIndex = trimmed.indexOf(" ");
    if (firstSpaceIndex === -1) {
      throw new Error(`Invalid unit value format: ${valueString}`);
    }

    const valueStringLocal = trimmed.substring(0, firstSpaceIndex);
    const unitsString = trimmed.substring(firstSpaceIndex + 1);

    const cleanedValue = valueStringLocal.replace(/,/g, "");
    const numericValue = parseFloat(cleanedValue);

    if (isNaN(numericValue)) {
      throw new Error(`Invalid numeric value: ${valueStringLocal}`);
    }

    return new EngineNumber(numericValue, unitsString, valueStringLocal.trim());
  }

  const match = trimmed.match(UNIT_VALUE_REGEX_FULL);

  if (!match) {
    return null;
  }

  const valueStringClean = match[1];
  const unitsString = match[2];

  const cleanedValue = valueStringClean.replace(/[,%]/g, "");
  const numericValue = parseFloat(cleanedValue);

  if (isNaN(numericValue)) {
    return null;
  }

  const finalUnits = valueStringClean.includes("%") ?
    "% " + unitsString : unitsString;

  return new EngineNumber(numericValue, finalUnits, valueStringClean.trim());
}

export {
  MetaSerializer,
  MetaChangeApplier,
  SubstanceMetadataUpdate,
  SubstanceMetadataError,
  SubstanceMetadataParseResult,
  FieldParseResult,
  ValidationResult,
  MetadataValidator,
  ValidationError,
  parseUnitValue,
};