Source: ui_translator_components.js

/**
 * Component classes for UI translation.
 *
 * Contains the main data model classes for representing programs, applications,
 * scenarios, substances, and commands in the QubecTalk UI editor.
 *
 * @license BSD, see LICENSE.md
 */

import {EngineNumber} from "engine_number";
import {YearMatcher, ParsedYear} from "duration";
import {parseUnitValue} from "meta_serialization";
import {
  formatEngineNumber,
  indentSingle,
  indent,
  buildAddCode,
  finalizeCodePieces,
} from "ui_translator_util";
import {COMMAND_COMPATIBILITIES, SUPPORTED_EQUALS_UNITS} from "ui_editor_const";
import {
  AssumeCommand,
  Command,
  IncompatibleCommand,
  LimitCommand,
  RechargeCommand,
  RecycleCommand,
  ReplaceCommand,
  RetireCommand,
} from "ui_translator_commands";

const toolkit = QubecTalk.getToolkit();

class Program {
  /**
   * Create a new Program.
   *
   * @param {Application[]} applications - Array of application definitions.
   * @param {DefinitionalStanza[]} policies - Array of policy definitions.
   * @param {SimulationScenario[]} scenarios - Array of simulation scenarios.
   * @param {boolean} isCompatible - Whether program is compatible with UI editing.
   */
  constructor(applications, policies, scenarios, isCompatible) {
    const self = this;
    self._applications = applications;
    self._policies = policies;
    self._scenarios = scenarios;
    self._isCompatible = isCompatible && self._passesTempCompatiblityTests();
  }

  /**
   * Get all substances across all applications.
   *
   * @returns {Substance[]} Array of all substances.
   */
  getSubstances() {
    const self = this;
    return self.getApplications()
      .map((x) => x.getSubstances())
      .flat();
  }

  /**
   * Insert or updates a substance in an application.
   *
   * @param {string} priorApplication - Name of application to remove from or
   *     null if no prior.
   * @param {string} newApplication - Name of application to insert into.
   * @param {string} priorSubstanceName - Name of substance to replace. Pass
   *     null for new.
   * @param {Substance} substance - The substance to insert.
   */
  insertSubstance(priorApplication, newApplication, priorSubstanceName, substance) {
    const self = this;

    if (priorApplication !== null) {
      const priorAppObj = self.getApplication(priorApplication);
      priorAppObj.deleteSubstance(priorSubstanceName);
    }

    const newAppObj = self.getApplication(newApplication);
    newAppObj.insertSubstance(null, substance);
  }

  /**
   * Delete a substance from an application.
   *
   * @param {string} applicationName - Name of application containing
   *     substance.
   * @param {string} substanceName - Name of substance to delete.
   */
  deleteSubstance(applicationName, substanceName) {
    const self = this;
    const application = self.getApplication(applicationName);
    application.deleteSubstance(substanceName);
    self._policies = self._policies.filter((x) => {
      const application = x.getApplications()[0];
      const substance = application.getSubstances()[0];
      const candidateName = substance.getName();
      return candidateName !== substanceName;
    });
    self._removeUnknownPoliciesFromScenarios();
  }

  /**
   * Get all applications.
   *
   * @returns {Application[]} Array of applications.
   */
  getApplications() {
    const self = this;
    return self._applications;
  }

  /**
   * Gets an application by name.
   *
   * @param {string} name - Name of application to find.
   * @returns {Application|null} The application or null if not found.
   */
  getApplication(name) {
    const self = this;
    const matching = self._applications.filter((x) => x.getName() === name);
    return matching.length == 0 ? null : matching[0];
  }

  /**
   * Add a new application.
   *
   * @param {Application} newApplication - Application to add.
   */
  addApplication(newApplication) {
    const self = this;
    self._applications.push(newApplication);
  }

  /**
   * Delete an application by name.
   *
   * @param {string} name - Name of application to delete.
   */
  deleteApplication(name) {
    const self = this;
    self._applications = self._applications.filter((x) => x.getName() !== name);
    self._policies = self._policies.filter((x) => x.getApplications()[0].getName() !== name);
    self._removeUnknownPoliciesFromScenarios();
  }

  /**
   * Rename an application.
   *
   * @param {string} oldName - Current name of application.
   * @param {string} newName - New name for application.
   */
  renameApplication(oldName, newName) {
    const self = this;
    const priorApplications = self._applications.filter((x) => x.getName() === oldName);
    priorApplications.forEach((x) => x.rename(newName));

    // Update applications within policies
    self._policies.forEach((policy) => {
      const policyApplications = policy.getApplications().filter((x) => x.getName() === oldName);
      policyApplications.forEach((x) => x.rename(newName));
    });
  }

  /**
   * Rename a substance within a specific application.
   *
   * @param {string} applicationName - Name of the application containing the substance.
   * @param {string} oldSubstanceName - Current name of the substance.
   * @param {string} newSubstanceName - New name for the substance.
   */
  renameSubstanceInApplication(applicationName, oldSubstanceName, newSubstanceName) {
    const self = this;

    // Update substances in main applications
    const targetApplications = self._applications.filter(
      (x) => x.getName() === applicationName,
    );
    targetApplications.forEach((app) => {
      const substances = app.getSubstances().filter((x) => x.getName() === oldSubstanceName);
      substances.forEach((x) => x.rename(newSubstanceName));
    });

    // Update substances within policies for the same application
    self._policies.forEach((policy) => {
      const policyApplications = policy.getApplications().filter(
        (x) => x.getName() === applicationName,
      );
      policyApplications.forEach((app) => {
        const substances = app.getSubstances().filter((x) => x.getName() === oldSubstanceName);
        substances.forEach((x) => x.rename(newSubstanceName));
      });
    });
  }

  /**
   * Get all policies.
   *
   * @returns {DefinitionalStanza[]} Array of policies.
   */
  getPolicies() {
    const self = this;
    return self._policies;
  }

  /**
   * Get a policy by name.
   *
   * @param {string} name - Name of policy to find.
   * @returns {DefinitionalStanza|null} The policy or null if not found.
   */
  getPolicy(name) {
    const self = this;
    const matching = self._policies.filter((x) => x.getName() === name);
    return matching.length == 0 ? null : matching[0];
  }

  /**
   * Delete a policy by name.
   *
   * @param {string} name - Name of policy to delete.
   * @param {boolean} [filterUnknown=true] - Whether to filter unknown policies.
   */
  deletePolicy(name, filterUnknown) {
    const self = this;

    if (filterUnknown === undefined) {
      filterUnknown = true;
    }

    self._policies = self._policies.filter((x) => x.getName() !== name);

    if (filterUnknown) {
      self._removeUnknownPoliciesFromScenarios();
    }
  }

  /**
   * Insert or update a policy.
   *
   * @param {string} oldName - Name of policy to replace, or null for new.
   * @param {DefinitionalStanza} newPolicy - Policy to insert.
   */
  insertPolicy(oldName, newPolicy) {
    const self = this;
    const nameChange = oldName !== newPolicy.getName();
    self.deletePolicy(oldName, nameChange);
    self._policies.push(newPolicy);
  }

  /**
   * Get all simulation scenarios.
   *
   * @returns {SimulationScenario[]} Array of scenarios.
   */
  getScenarios() {
    const self = this;
    return self._scenarios;
  }

  /**
   * Get the names of all simulation scenarios.
   *
   * Convenience method to extract just the scenario names without
   * needing to access full SimulationScenario objects. Filters out
   * IncompatibleCommand objects (e.g., from "across X trials" syntax).
   *
   * @returns {string[]} Array of scenario names in order they appear.
   */
  getScenarioNames() {
    const self = this;
    return self._scenarios
      .filter((scenario) => scenario.getIsCompatible())
      .map((scenario) => scenario.getName());
  }

  /**
   * Get a simulation scenario by name.
   *
   * @param {string} name - Name of scenario to find.
   * @returns {SimulationScenario|null} The scenario or null if not found.
   */
  getScenario(name) {
    const self = this;
    const matching = self._scenarios.filter((x) => x.getName() === name);
    return matching.length == 0 ? null : matching[0];
  }

  /**
   * Delete a simulation scenario by name.
   *
   * @param {string} name - Name of scenario to delete.
   */
  deleteScenario(name) {
    const self = this;
    self._scenarios = self._scenarios.filter((x) => x.getName() !== name);
  }

  /**
   * Insert or update a simulation scenario.
   *
   * @param {string} oldName - Name of scenario to replace, or null for new.
   * @param {SimulationScenario} scenario - Scenario to insert.
   */
  insertScenario(oldName, scenario) {
    const self = this;
    self.deleteScenario(oldName);
    self._scenarios.push(scenario);
  }

  /**
   * Gets whether program is compatible with UI editing.
   *
   * @returns {boolean} True if compatible, false otherwise.
   */
  getIsCompatible() {
    const self = this;
    return self._isCompatible;
  }

  /**
   * Generates the code representation of the program with the specified indentation.
   *
   * @param {number} spaces - Number of spaces to use for indenting the generated code.
   * @returns {string} The code representation of the program with specified indentation.
   */
  toCode(spaces) {
    const self = this;

    const baselinePieces = [];
    const addCode = buildAddCode(baselinePieces);

    if (self.getApplications().length > 0) {
      const applicationsCode = self.getApplications()
        .map((x) => x.toCode(spaces + 2))
        .join("\n\n\n");

      addCode("start default", spaces);
      addCode("", spaces);
      addCode(applicationsCode, 0);
      addCode("", spaces);
      addCode("end default", spaces);
      addCode("", spaces);
      addCode("", spaces);
    }

    if (self.getPolicies().length > 0) {
      const policiesCode = self.getPolicies()
        .map((x) => x.toCode(spaces))
        .join("\n\n\n\n");
      addCode(policiesCode, spaces);
      addCode("", spaces);
      addCode("", spaces);
    }

    if (self.getScenarios().length > 0) {
      addCode("start simulations", spaces);
      addCode("", spaces);
      const scenariosCode = self.getScenarios()
        .map((x) => x.toCode(2))
        .join("\n\n\n");
      addCode(scenariosCode, spaces);
      addCode("", spaces);
      addCode("end simulations", spaces);
    }

    return finalizeCodePieces(baselinePieces);
  }

  /**
   * Removing policies that are not compatible with the UI editor.
   *
   * Filters each scenario to include only the policies that are in the known
   * policies list that are compatible with the UI-based editor. It
   * subsequently updates each scenario with the filtered list of policies.
   *
   * @private
   */
  _removeUnknownPoliciesFromScenarios() {
    const self = this;
    const knownPolicies = new Set(self._policies.map((x) => x.getName()));
    self._scenarios = self._scenarios.map((scenario) => {
      if (!scenario.getIsCompatible()) {
        return scenario;
      }

      const name = scenario.getName();
      const start = scenario.getYearStart();
      const end = scenario.getYearEnd();

      const selectedPolicies = scenario.getPolicyNames();
      const allowedPolicies = selectedPolicies.filter((x) => knownPolicies.has(x));

      return new SimulationScenario(name, allowedPolicies, start, end, true);
    });
  }

  /**
   * Determine if the compatibility tests are passed.
   *
   * Evaluate the compatibility of applications and policies with specific
   * conditions that must be satisfied to pass the compatibility tests.
   *
   * @private
   * @returns {boolean} True if all temporary compatibility tests are passed or
   *     false otherwise.
   */
  _passesTempCompatiblityTests() {
    const self = this;

    const problematicApplications = self._applications.filter((application) => {
      const substances = application.getSubstances();
      const problematicSubstances = substances.filter((substance) => {
        const durationIsFullSpan = (duration) => {
          if (duration === null) {
            return true;
          }
          const durationFullSpan = duration.getStart() === null && duration.getEnd() === null;
          return durationFullSpan;
        };

        const getInitialChargeProblematic = () => {
          const initialCharges = substance.getInitialCharges();
          const uniqueTargets = new Set(initialCharges.map((x) => x.getTarget()));
          if (uniqueTargets.size != initialCharges.length) {
            return true;
          }
          const initialChargesWithDuration = initialCharges.filter((initialCharge) => {
            const duration = initialCharge.getDuration();
            return !durationIsFullSpan(duration);
          });
          if (initialChargesWithDuration.length > 0) {
            return true;
          }
        };

        const getEqualsProblematic = (equals) => {
          if (equals === null) {
            return false;
          } else {
            const duration = equals.getDuration();

            if (!durationIsFullSpan(duration)) {
              return true;
            }

            const value = equals.getValue();
            const units = value.getUnits();
            if (!SUPPORTED_EQUALS_UNITS.includes(units)) {
              return true;
            }

            return false;
          }
        };

        const ghgProblematic = getEqualsProblematic(substance.getEqualsGhg());
        const kwhProblematic = getEqualsProblematic(substance.getEqualsKwh());
        const equalsProblematic = ghgProblematic || kwhProblematic;

        return getInitialChargeProblematic() || equalsProblematic;
      });
      return problematicSubstances.length > 0;
    });

    const applicationsOk = problematicApplications.length == 0;

    const problematicPolicies = self._policies.filter((policy) => {
      const applications = policy.getApplications();
      if (applications.length != 1) {
        return true;
      }

      const application = applications[0];
      const substances = application.getSubstances();
      if (substances.length != 1) {
        return true;
      }

      return false;
    });

    const policiesOk = problematicPolicies.length == 0;

    return applicationsOk && policiesOk;
  }
}

/**
 * An "about" stanza in the QubecTalk script.
 */
class AboutStanza {
  /**
   * Gets the name of this stanza.
   * @returns {string} The stanza name "about".
   */
  getName() {
    const self = this;
    return "about";
  }

  /**
   * Generates the code representation of the about stanza.
   *
   * @param {number} spaces - Number of spaces for indentation.
   * @returns {string} Code representation of the stanza.
   */
  toCode(spaces) {
    const self = this;

    const baselinePieces = [];
    const addCode = buildAddCode(baselinePieces);

    addCode("start about", spaces);
    addCode("end about", spaces);

    return finalizeCodePieces(baselinePieces);
  }

  /**
   * Checks compatibility of the about stanza with UI editing.
   *
   * @returns {boolean} False as about stanza is not compatible with UI.
   */
  getIsCompatible() {
    const self = this;
    return false;
  }
}

/**
 * Definitional stanza that can contain application and / or policies.
 */
class DefinitionalStanza {
  /**
   * Create a new DefinitionalStanza.
   *
   * @param {string} name - Name of the stanza.
   * @param {Application[]} applications - Array of applications.
   * @param {boolean} isCompatible - Whether stanza is UI-compatible.
   */
  constructor(name, applications, isCompatible) {
    const self = this;
    self._name = name;
    self._applications = applications;
    self._isCompatible = isCompatible;
  }

  /**
   * Get the name of this definitional stanza.
   *
   * @returns {string} The name of the stanza ("default" or policy name).
   */
  getName() {
    const self = this;
    return self._name;
  }

  /**
   * Get the applications defined in this stanza.
   *
   * @returns {Application[]} Array of applications defined in the stanza.
   */
  getApplications() {
    const self = this;
    return self._applications;
  }

  /**
   * Check if this stanza is compatible with UI editing.
   *
   * @returns {boolean} True if stanza can be edited in UI, false otherwise.
   */
  getIsCompatible() {
    const self = this;
    return self._isCompatible;
  }

  /**
   * Generate the code representation of this stanza.
   *
   * Generate the QubecTalk code representation of this definitional stanza,
   * including all its applications and appropriate indentation.
   *
   * @param {number} spaces - Number of spaces to use for indentation.
   * @returns {string} The code representation of the stanza.
   */
  toCode(spaces) {
    const self = this;

    const baselinePieces = [];
    const addCode = buildAddCode(baselinePieces);
    const isDefault = self.getName() === "default";

    addCode("start " + (isDefault ? "default" : 'policy "' + self.getName() + '"'), spaces);
    addCode("", spaces);

    if (self.getApplications().length > 0) {
      const applicationsCode = self.getApplications()
        .map((x) => x.toCode(spaces + 2))
        .join("\n\n\n");
      addCode(applicationsCode, 0);
    }

    addCode("", spaces);
    addCode("end " + (isDefault ? "default" : "policy"), spaces);

    return finalizeCodePieces(baselinePieces);
  }
}

/**
 * Represent a simulation scenario that applies policies over a time period.
 */
class SimulationScenario {
  /**
   * Create a new SimulationScenario.
   *
   * @param {string} name - Name of the scenario.
   * @param {string[]} policyNames - Array of policy names to apply.
   * @param {number} yearStart - Start year of simulation.
   * @param {number} yearEnd - End year of simulation.
   * @param {boolean} isCompatible - Whether scenario is UI-compatible.
   */
  constructor(name, policyNames, yearStart, yearEnd, isCompatible) {
    const self = this;
    self._name = name;
    self._policyNames = policyNames;
    self._isCompatible = isCompatible;

    const yearStartRearrange = Math.min(yearStart, yearEnd);
    const yearEndRearrange = Math.max(yearStart, yearEnd);

    self._yearStart = yearStartRearrange;
    self._yearEnd = yearEndRearrange;
  }

  /**
   * Get the name of this simulation scenario.
   *
   * @returns {string} The scenario name.
   */
  getName() {
    const self = this;
    return self._name;
  }

  /**
   * Get names of policies included in this scenario.
   *
   * @returns {string[]} Array of policy names to apply.
   */
  getPolicyNames() {
    const self = this;
    return self._policyNames;
  }

  /**
   * Get the start year of the simulation.
   *
   * @returns {number} The year the simulation starts.
   */
  getYearStart() {
    const self = this;
    return self._yearStart;
  }

  /**
   * Get the end year of the simulation.
   *
   * @returns {number} The year the simulation ends.
   */
  getYearEnd() {
    const self = this;
    return self._yearEnd;
  }

  /**
   * Check if this scenario is compatible with UI editing.
   *
   * @returns {boolean} True if scenario can be edited in UI, false otherwise.
   */
  getIsCompatible() {
    const self = this;
    return self._isCompatible;
  }

  /**
   * Generate the code representation of this scenario.
   *
   * @param {number} spaces - Number of spaces to use for indentation.
   * @returns {string} The code representation of the simulation scenario.
   */
  toCode(spaces) {
    const self = this;

    const baselinePieces = [];
    const addCode = buildAddCode(baselinePieces);

    addCode('simulate "' + self.getName() + '"', spaces);

    if (self.getPolicyNames().length > 0) {
      self.getPolicyNames().forEach((x, i) => {
        const prefix = i == 0 ? "using" : "then";
        addCode(prefix + ' "' + x + '"', spaces + 2);
      });
    }

    addCode("from years " + self.getYearStart() + " to " + self.getYearEnd(), spaces);
    return finalizeCodePieces(baselinePieces);
  }
}

/**
 * Simulations stanza that contains multiple simulation scenarios.
 */
class SimulationStanza {
  /**
   * Create a new SimulationStanza.
   *
   * @param {SimulationScenario[]} scenarios - Array of simulation scenarios.
   * @param {boolean} isCompatible - Whether stanza is compatible with UI editing.
   */
  constructor(scenarios, isCompatible) {
    const self = this;
    self._scenarios = scenarios;
    self._isCompatible = isCompatible;
  }

  /**
   * Check if this stanza is compatible with UI editing.
   *
   * @returns {boolean} True if stanza can be edited in UI, false otherwise.
   */
  getIsCompatible() {
    const self = this;
    return self._isCompatible;
  }

  /**
   * Get the simulation scenarios in this stanza.
   *
   * @returns {SimulationScenario[]} Array of simulation scenarios.
   */
  getScenarios() {
    const self = this;
    return self._scenarios;
  }

  /**
   * Get the name of this stanza.
   *
   * @returns {string} The string "simulations".
   */
  getName() {
    const self = this;
    return "simulations";
  }

  /**
   * Generate the code representation of this stanza.
   *
   * Generates the QubecTalk code representation of this simulations stanza,
   * including all its scenarios and appropriate indentation.
   *
   * @param {number} spaces - Number of spaces to use for indentation.
   * @returns {string} The code representation of the stanza.
   */
  toCode(spaces) {
    const self = this;

    const baselinePieces = [];
    const addCode = buildAddCode(baselinePieces);

    addCode("start simulations", spaces);

    if (self.getScenarios().length > 0) {
      addCode("", spaces);
      const scenariosCode = self
        .getScenarios()
        .map((x) => x.toCode(2))
        .join("\n\n\n");
      addCode(scenariosCode, spaces);
      addCode("", spaces);
    }

    addCode("end simulations", spaces);
    return finalizeCodePieces(baselinePieces);
  }
}

/**
 * Represent an application that contains substances and their properties.
 */
class Application {
  /**
   * Create a new Application.
   *
   * @param {string} name - Name of the application.
   * @param {Substance[]} substances - Array of substances.
   * @param {boolean} isModification - Whether this modifies existing application.
   * @param {boolean} isCompatible - Whether application is UI-compatible.
   */
  constructor(name, substances, isModification, isCompatible) {
    const self = this;
    self._name = name;
    self._substances = substances;
    self._isModification = isModification;
    self._isCompatible = isCompatible;
  }

  /**
   * Get the name of this application.
   *
   * @returns {string} The application name.
   */
  getName() {
    const self = this;
    return self._name;
  }

  /**
   * Rename this application.
   *
   * @param {string} newName - The new name for the application.
   */
  rename(newName) {
    const self = this;
    self._name = newName;
  }

  /**
   * Get all substances defined in this application.
   *
   * @returns {Substance[]} Array of substances.
   */
  getSubstances() {
    const self = this;
    return self._substances;
  }

  /**
   * Insert or update a substance in this application.
   *
   * @param {string} substanceName - Name of substance to replace, or null for new.
   * @param {Substance} newVersion - The substance to insert.
   */
  insertSubstance(substanceName, newVersion) {
    const self = this;
    self.deleteSubstance(substanceName);
    self._substances.push(newVersion);
  }

  /**
   * Delete a substance from this application.
   *
   * @param {string} substanceName - Name of substance to delete.
   */
  deleteSubstance(substanceName) {
    const self = this;
    self._substances = self._substances.filter((x) => x.getName() !== substanceName);
  }

  /**
   * Get a specific substance by name.
   *
   * @param {string} name - Name of substance to find.
   * @returns {Substance|null} The substance or null if not found.
   */
  getSubstance(name) {
    const self = this;
    const matching = self._substances.filter((x) => x.getName() === name);
    return matching.length == 0 ? null : matching[0];
  }

  /**
   * Check if this application modifies an existing one.
   *
   * @returns {boolean} True if this modifies an existing application.
   */
  getIsModification() {
    const self = this;
    return self._isModification;
  }

  /**
   * Check if this application is compatible with UI editing.
   *
   * @returns {boolean} True if application can be edited in UI.
   */
  getIsCompatible() {
    const self = this;
    return self._isCompatible;
  }

  /**
   * Generate the code representation of this application.
   *
   * @param {number} spaces - Number of spaces to use for indentation.
   * @returns {string} The code representation of the application.
   */
  toCode(spaces) {
    const self = this;

    const baselinePieces = [];
    const addCode = buildAddCode(baselinePieces);

    const prefix = self.getIsModification() ? "modify" : "define";
    addCode(prefix + ' application "' + self.getName() + '"', spaces);

    if (self.getSubstances().length > 0) {
      addCode("", spaces);
      const substancesCode = self.getSubstances()
        .map((x) => x.toCode(spaces + 2))
        .join("\n\n\n");
      addCode(substancesCode, 0);
      addCode("", spaces);
    }

    addCode("end application", spaces);
    return finalizeCodePieces(baselinePieces);
  }
}

/**
 * Build substances with their properties and commands.
 *
 * Provides a stateful interface for constructing Substances with various
 * commands and properties.
 */
class SubstanceBuilder {
  /**
   * Create a new SubstanceBuilder.
   *
   * @param {string} name - Name of the substance.
   * @param {boolean} isModification - Whether this modifies an existing substance.
   */
  constructor(name, isModification) {
    const self = this;
    self._name = name;
    self._isModification = isModification;
    self._initialCharges = [];
    self._limits = [];
    self._changes = [];
    self._equalsGhg = null;
    self._equalsKwh = null;
    self._recharges = [];
    self._recycles = [];
    self._replaces = [];
    self._retire = null;
    self._setVals = [];
    self._enables = [];
    self._assumeMode = null;
    self._isCompatibleOverride = null;
  }

  /**
   * Build a new Substance from the current state.
   *
   * @param {boolean} isCompatibleRaw - Whether substance should be UI-compatible.
   * @returns {Substance} The constructed substance.
   */
  build(isCompatibleRaw) {
    const self = this;

    const commandsConsolidatedInterpreted = [
      self._enables,
      self._initialCharges,
      self._limits,
      self._recycles,
      self._replaces,
      [self._equalsGhg, self._equalsKwh, self._retire],
      self._recharges,
      self._changes,
      self._setVals,
    ].flat();
    const isCompatibleInterpreted = commandsConsolidatedInterpreted
      .filter((x) => x !== null)
      .map((x) => x.getIsCompatible())
      .reduce((a, b) => a && b, true);

    const initialChargeTargets = self._initialCharges.map((x) => x.getTarget());
    const initialChargeTargetsUnique = new Set(initialChargeTargets);
    const initialChargesNonOverlap = initialChargeTargets.length == initialChargeTargetsUnique.size;

    const isCompatibleComputed =
      isCompatibleRaw && isCompatibleInterpreted && initialChargesNonOverlap;
    const isCompatible =
      self._isCompatibleOverride !== null ?
        self._isCompatibleOverride :
        isCompatibleComputed;

    return new Substance(
      self._name,
      self._initialCharges,
      self._limits,
      self._changes,
      self._equalsGhg,
      self._equalsKwh,
      self._recharges,
      self._recycles,
      self._replaces,
      self._retire,
      self._setVals,
      self._enables,
      self._isModification,
      isCompatible,
      self._assumeMode,
    );
  }

  /**
   * Add a command to the substance being built.
   *
   * @param {Command} command - The command to add.
   * @returns {Command|IncompatibleCommand} The added command or incompatibility marker.
   */
  addCommand(command) {
    const self = this;

    const commandType = command.getTypeName();
    const compatibilityType = COMMAND_COMPATIBILITIES[commandType];
    if (compatibilityType === undefined) {
      throw "Unknown compatibility type for " + commandType;
    }

    const requiresMod = compatibilityType === "policy";
    const requiresDefinition = compatibilityType === "definition";
    const noCompat = compatibilityType === "none";

    const needsToMoveToMod = requiresMod && !self._isModification;
    const needsToMoveToDefinition = requiresDefinition && self._isModification;
    const incompatiblePlace = needsToMoveToMod || needsToMoveToDefinition || noCompat;

    const strategy = {
      "change": (x) => self.addChange(x),
      "retire": (x) => self.setRetire(x),
      "setVal": (x) => self.addSetVal(x),
      "initial charge": (x) => self.addInitialCharge(x),
      "recharge": (x) => self.addRecharge(x),
      "equals": (x) => {
        const units = x.getValue().getUnits();
        if (units.includes("kwh")) {
          return self.setEqualsKwh(x);
        } else {
          return self.setEqualsGhg(x);
        }
      },
      "recycle": (x) => self.addRecycle(x),
      "cap": (x) => self.addLimit(x),
      "floor": (x) => self.addLimit(x),
      "replace": (x) => self.addReplace(x),
      "enable": (x) => self.addEnable(x),
      "assume": (x) => self.setAssumeMode(x.getMode()),
    }[commandType];

    if (incompatiblePlace) {
      return self._makeInvalidPlacement();
    } else {
      return strategy(command);
    }
  }

  /**
   * Set the name of the substance.
   *
   * @param {string} newVal - New name for the substance.
   */
  setName(newVal) {
    const self = this;
    self._name = newVal;
  }

  /**
   * Add an initial charge command.
   *
   * @param {Command} newVal - Initial charge command to add.
   */
  addInitialCharge(newVal) {
    const self = this;
    self._initialCharges.push(newVal);
  }

  /**
   * Add a limit command.
   *
   * @param {LimitCommand} newVal - Limit command to add.
   */
  addLimit(newVal) {
    const self = this;
    self._limits.push(newVal);
  }

  /**
   * Add a change command.
   *
   * @param {Command} newVal - Change command to add.
   */
  addChange(newVal) {
    const self = this;
    self._changes.push(newVal);
  }

  /**
   * Set the equals command.
   *
   * @param {Command} newVal - Equals command to set.
   * @returns {Command|IncompatibleCommand} The command or incompatibility marker.
   */
  setEqualsGhg(newVal) {
    const self = this;
    self._equalsGhg = self._checkDuplicate(self._equalsGhg, newVal);
  }

  /**
   * Set the energy consumption equals command.
   *
   * @param {Command} newVal - Energy equals command to set.
   * @returns {Command|IncompatibleCommand} The command or incompatibility marker.
   */
  setEqualsKwh(newVal) {
    const self = this;
    self._equalsKwh = self._checkDuplicate(self._equalsKwh, newVal);
  }

  /**
   * Add a recharge command.
   *
   * @param {Command} newVal - Recharge command to add.
   */
  addRecharge(newVal) {
    const self = this;
    self._recharges.push(newVal);
  }

  /**
   * Add a recycle command.
   *
   * @param {Command} newVal - Recycle command to add.
   */
  addRecycle(newVal) {
    const self = this;
    self._recycles.push(newVal);
  }

  /**
   * Add a replace command.
   *
   * @param {ReplaceCommand} newVal - Replace command to add.
   */
  addReplace(newVal) {
    const self = this;
    self._replaces.push(newVal);
  }

  /**
   * Set the retire command.
   *
   * @param {Command} newVal - Retire command to set.
   * @returns {Command|IncompatibleCommand} The command or incompatibility marker.
   */
  setRetire(newVal) {
    const self = this;
    self._retire = self._checkDuplicate(self._retire, newVal);
  }

  /**
   * Set the sales assumption mode for this substance.
   *
   * @param {string|null} mode - The assumption mode: "continued", "only recharge", "no", or null.
   */
  setAssumeMode(mode) {
    const self = this;
    self._assumeMode = mode;
  }

  /**
   * Override the compatibility flag for this substance.
   *
   * @param {boolean} isCompatible - Whether the substance is UI-compatible.
   */
  setIsCompatible(isCompatible) {
    const self = this;
    self._isCompatibleOverride = isCompatible;
  }

  /**
   * Add a set value command.
   *
   * @param {Command} newVal - Set value command to add.
   */
  addSetVal(newVal) {
    const self = this;
    self._setVals.push(newVal);
  }

  /**
   * Add an enable command to this substance.
   *
   * @param {Command} enable - The enable command to add.
   */
  addEnable(enable) {
    const self = this;
    self._enables.push(enable);
  }

  /**
   * Check for duplicate single-value commands.
   *
   * @param {Command|null} originalVal - Existing command if any.
   * @param {Command} newVal - New command to check.
   * @returns {Command|IncompatibleCommand} The command or incompatibility marker.
   * @private
   */
  _checkDuplicate(originalVal, newVal) {
    if (originalVal === null) {
      return newVal;
    } else {
      return new IncompatibleCommand("duplicate");
    }
  }

  /**
   * Create an incompatible command for invalid placement.
   *
   * @returns {IncompatibleCommand} An incompatibility marker.
   * @private
   */
  _makeInvalidPlacement() {
    const self = this;
    return new IncompatibleCommand("invalid placement");
  }
}

/**
 * Substance configuration including consumption patterns and lifecycle.
 *
 * Represents a single substance (e.g., HFC-134a, R-410A) used within an application.
 * Contains GWP values, initial charges, consumption/sales streams (domestic, import, export),
 * retirement rates, recharge schedules, and sales assumptions for bank tracking.
 *
 * Sales assumptions control carryover behavior:
 * - "continued": Sales continue from previous year (default, no-op)
 * - "only recharge": New sales limited to servicing existing equipment
 * - "no": All sales reset to zero
 *
 * UI editor supports max 1 assume command for sales stream without duration.
 * Advanced editor supports multiple assumes, durations, and other streams (domestic, import, bank).
 */
class Substance {
  /**
   * Create a new Substance.
   *
   * @param {string} name - Name of the substance.
   * @param {Command[]} charges - Initial charge commands.
   * @param {LimitCommand[]} limits - Limit commands.
   * @param {Command[]} changes - Change commands.
   * @param {Command} equals - Equals command.
   * @param {Command[]} recharges - Recharge commands.
   * @param {Command[]} recycles - Recycle commands.
   * @param {ReplaceCommand[]} replaces - Replace commands.
   * @param {Command} retire - Retire command.
   * @param {Command[]} setVals - Set value commands.
   * @param {Command[]} enables - Enable commands.
   * @param {boolean} isMod - Whether this modifies existing substance.
   * @param {boolean} compat - Whether substance is UI-compatible.
   * @param {string|null} assumeMode - Sales assumption mode.
   */
  constructor(
    name,
    charges,
    limits,
    changes,
    equalsGhg,
    equalsKwh,
    recharges,
    recycles,
    replaces,
    retire,
    setVals,
    enables,
    isMod,
    compat,
    assumeMode,
  ) {
    const self = this;
    self._name = name;
    self._initialCharges = charges;
    self._limits = limits;
    self._changes = changes;
    self._equalsGhg = equalsGhg;
    self._equalsKwh = equalsKwh;
    self._recharges = recharges;
    self._recycles = recycles;
    self._replaces = replaces;
    self._retire = retire;
    self._setVals = setVals;
    self._enables = enables;
    self._isModification = isMod;
    self._isCompatible = compat;
    self._assumeMode = assumeMode;
  }

  /**
   * Get the name of this substance.
   *
   * @returns {string} The substance name like HFC-134a.
   */
  getName() {
    const self = this;
    return self._name;
  }

  /**
   * Rename this substance.
   *
   * @param {string} newName - New name for the substance.
   */
  rename(newName) {
    const self = this;
    // Simply update the name directly for rename operation
    self._name = newName;
  }

  /**
   * Update all metadata for this substance using a SubstanceMetadata object.
   *
   * @param {SubstanceMetadata} newMetadata - New metadata to apply to this substance.
   * @param {string} applicationName - Name of the application this substance belongs to.
   */
  updateMetadata(newMetadata, applicationName) {
    const self = this;

    // Validate input
    if (!newMetadata || !(newMetadata instanceof SubstanceMetadata)) {
      throw new Error("newMetadata must be a SubstanceMetadata instance");
    }

    // Use imported parseUnitValue function from meta_serialization module

    // Update name
    const fullName = newMetadata.getName();
    self._name = fullName;

    // Update GHG equals command
    const ghgValue = parseUnitValue(newMetadata.getGhg(), true);
    if (ghgValue) {
      self._equalsGhg = new Command("equals", null, ghgValue, null);
    } else {
      self._equalsGhg = null;
    }

    // Update energy equals command
    const energyValue = parseUnitValue(newMetadata.getEnergy(), true);
    if (energyValue) {
      self._equalsKwh = new Command("equals", null, energyValue, null);
    } else {
      self._equalsKwh = null;
    }

    // Update enabled streams
    self._enables = [];
    if (newMetadata.getHasDomestic()) {
      self._enables.push(new Command("enable", "domestic", null, null));
    }
    if (newMetadata.getHasImport()) {
      self._enables.push(new Command("enable", "import", null, null));
    }
    if (newMetadata.getHasExport()) {
      self._enables.push(new Command("enable", "export", null, null));
    }

    // Update initial charges
    self._initialCharges = [];

    const domesticCharge = parseUnitValue(newMetadata.getInitialChargeDomestic(), true);
    if (domesticCharge) {
      const cmd = new Command("initial charge", "domestic", domesticCharge, null);
      self._initialCharges.push(cmd);
    }

    const importCharge = parseUnitValue(newMetadata.getInitialChargeImport(), true);
    if (importCharge) {
      const cmd = new Command("initial charge", "import", importCharge, null);
      self._initialCharges.push(cmd);
    }

    const exportCharge = parseUnitValue(newMetadata.getInitialChargeExport(), true);
    if (exportCharge) {
      const cmd = new Command("initial charge", "export", exportCharge, null);
      self._initialCharges.push(cmd);
    }

    // Update retirement command (Component 5: includes withReplacement)
    const retirementValue = parseUnitValue(newMetadata.getRetirement(), true);
    if (retirementValue) {
      // Parse withReplacement flag from metadata (stored as "true"/"false" string)
      const retirementWithReplacementStr = newMetadata.getRetirementWithReplacement();
      const withReplacement = retirementWithReplacementStr === "true";

      self._retire = new RetireCommand(retirementValue, null, withReplacement);
    } else {
      self._retire = null;
    }

    // Update assumeMode from metadata defaultSales (Component 2)
    // Note: metadata.getDefaultSales() returns normalized internal value
    const defaultSales = newMetadata.getDefaultSales();
    if (defaultSales && defaultSales.trim()) {
      const assumeMode = defaultSales.trim();
      // Set the assumeMode directly; empty string means use default (null)
      if (assumeMode === "continued" || assumeMode === "") {
        self._assumeMode = null; // null and "continued" are equivalent
      } else {
        self._assumeMode = assumeMode;
      }
    } else {
      // Empty or missing defaultSales means use default (null)
      self._assumeMode = null;
    }
  }

  /**
   * Get all initial charge commands for this substance.
   *
   * @returns {Command[]} Array of initial charge commands.
   */
  getInitialCharges() {
    const self = this;
    return self._initialCharges;
  }

  /**
   * Get the initial charge command for a specific stream.
   *
   * @param {string} stream - The stream to get initial charge for.
   * @returns {Command|null} The initial charge command or null if not found.
   */
  getInitialCharge(stream) {
    const self = this;
    const matching = self._initialCharges.filter((x) => x.getTarget() === stream);
    return matching.length == 0 ? null : matching[0];
  }

  /**
   * Get all limit commands for this substance.
   *
   * @returns {LimitCommand[]} Array of limit commands.
   */
  getLimits() {
    const self = this;
    return self._limits;
  }

  /**
   * Get all change commands for this substance.
   *
   * @returns {Command[]} Array of change commands.
   */
  getChanges() {
    const self = this;
    return self._changes;
  }

  /**
   * Get the GHG equals command for this substance.
   *
   * @returns {Command|null} The GHG equals command or null if not set.
   */
  getEqualsGhg() {
    const self = this;
    return self._equalsGhg;
  }

  /**
   * Get the energy consumption equals command for this substance.
   *
   * @returns {Command|null} The energy equals command or null if not set.
   */
  getEqualsKwh() {
    const self = this;
    return self._equalsKwh;
  }

  /**
   * Get all recharge commands for this substance.
   *
   * @returns {Command[]} Array of recharge commands.
   */
  getRecharges() {
    const self = this;
    return self._recharges;
  }

  /**
   * Get all recycle commands for this substance.
   *
   * @returns {Command[]} Array of recycle commands.
   */
  getRecycles() {
    const self = this;
    return self._recycles;
  }

  /**
   * Get all replace commands for this substance.
   *
   * @returns {ReplaceCommand[]} Array of replace commands.
   */
  getReplaces() {
    const self = this;
    return self._replaces;
  }

  /**
   * Get the retire command for this substance.
   *
   * @returns {Command|null} The retire command or null if not set.
   */
  getRetire() {
    const self = this;
    return self._retire;
  }

  /**
   * Get all set value commands for this substance.
   *
   * @returns {Command[]} Array of set value commands.
   */
  getSetVals() {
    const self = this;
    return self._setVals;
  }

  /**
   * Get all enable commands for this substance.
   *
   * @returns {Command[]} Array of enable commands.
   */
  getEnables() {
    const self = this;
    return self._enables;
  }

  /**
   * Get the sales assumption mode for this substance.
   *
   * @returns {string|null} The assumption mode: "continued", "only recharge",
   *   "no", or null for default.
   */
  getAssumeMode() {
    const self = this;
    return self._assumeMode;
  }

  /**
   * Check if this substance modifies an existing one.
   *
   * @returns {boolean} True if this modifies an existing substance.
   */
  getIsModification() {
    const self = this;
    return self._isModification;
  }

  /**
   * Check if this substance is compatible with UI editing.
   *
   * @returns {boolean} True if substance can be edited in UI.
   */
  getIsCompatible() {
    const self = this;
    return self._isCompatible;
  }

  /**
   * Get metadata representation of this substance for CSV export/import.
   *
   * This method extracts metadata from the current substance structure and
   * creates a SubstanceMetadata instance. The application name is provided
   * as a parameter since it's not stored within the substance itself.
   *
   * @param {string} applicationName - Name of the application this substance belongs to.
   * @returns {SubstanceMetadata} Metadata representation of the substance.
   */
  getMeta(applicationName) {
    const self = this;

    // Assert that applicationName is provided and non-empty
    if (!applicationName || applicationName.trim() === "") {
      throw new Error("applicationName must be provided and non-empty");
    }

    // Extract substance name and equipment type
    const fullName = self._name;
    let substance = fullName;
    let equipment = "";

    // Check if name contains equipment type (format: "substance - equipment")
    const dashIndex = fullName.indexOf(" - ");
    if (dashIndex > 0) {
      substance = fullName.substring(0, dashIndex);
      equipment = fullName.substring(dashIndex + 3);
    }

    // Extract GHG value
    let ghg = "";
    if (self._equalsGhg) {
      const ghgValue = self._equalsGhg.getValue();
      ghg = formatEngineNumber(ghgValue);
    }

    // Extract energy value
    let energy = "";
    if (self._equalsKwh) {
      const energyValue = self._equalsKwh.getValue();
      energy = formatEngineNumber(energyValue);
    }

    // Extract enabled streams
    let hasDomestic = false;
    let hasImport = false;
    let hasExport = false;

    self._enables.forEach((enable) => {
      const target = enable.getTarget();
      if (target === "domestic") {
        hasDomestic = true;
      } else if (target === "import") {
        hasImport = true;
      } else if (target === "export") {
        hasExport = true;
      }
    });

    // Extract initial charges
    let initialChargeDomestic = "";
    let initialChargeImport = "";
    let initialChargeExport = "";

    self._initialCharges.forEach((charge) => {
      const target = charge.getTarget();
      const chargeValue = charge.getValue();
      const chargeString = formatEngineNumber(chargeValue);

      if (target === "domestic") {
        initialChargeDomestic = chargeString;
      } else if (target === "import") {
        initialChargeImport = chargeString;
      } else if (target === "export") {
        initialChargeExport = chargeString;
      }
    });

    // Extract retirement rate and withReplacement flag
    let retirement = "";
    let retirementWithReplacement = "";
    if (self._retire) {
      const retireValue = self._retire.getValue();
      retirement = formatEngineNumber(retireValue);
      // Export withReplacement as "true" or "false" string
      const withReplacement = self._retire.getWithReplacement();
      retirementWithReplacement = withReplacement ? "true" : "false";
    }

    // Map assumeMode to user-facing defaultSales value
    const assumeMode = self.getAssumeMode();
    let defaultSales = "";
    if (assumeMode === null || assumeMode === "continued") {
      defaultSales = "continue from prior year";
    } else if (assumeMode === "only recharge") {
      defaultSales = "only servicing";
    } else if (assumeMode === "no") {
      defaultSales = "none";
    } else {
      // Default to "continue from prior year" for any unexpected values
      defaultSales = "continue from prior year";
    }

    return new SubstanceMetadata(
      substance,
      equipment,
      applicationName,
      ghg,
      hasDomestic,
      hasImport,
      hasExport,
      energy,
      initialChargeDomestic,
      initialChargeImport,
      initialChargeExport,
      retirement,
      retirementWithReplacement,
      defaultSales,
    );
  }

  /**
   * Generate the code representation of the substance.
   *
   * Translate the substance's properties and commands into their code
   * representation based on the number of spaces specified for the indentation.
   *
   * @param {number} spaces - Number of spaces to use for indentation.
   * @returns {string} The code representation of the substance.
   */
  toCode(spaces) {
    const self = this;

    const baselinePieces = [];
    const addCode = buildAddCode(baselinePieces);

    const prefix = self.getIsModification() ? "modify" : "uses";
    addCode(prefix + ' substance "' + self.getName() + '"', spaces);

    const addIfGiven = (code) => {
      if (code === null) {
        return;
      }
      addCode(code, spaces + 2);
    };

    const addAllIfGiven = (codeLines) => {
      if (codeLines === null) {
        return;
      }
      codeLines.forEach(addIfGiven);
    };

    addAllIfGiven(self._getEnablesCode());
    addIfGiven(self._getAssumeCode());
    addAllIfGiven(self._getInitialChargesCode());
    addIfGiven(self._getEqualsCode(self._equalsGhg));
    addIfGiven(self._getEqualsCode(self._equalsKwh));
    addAllIfGiven(self._getSetValsCode());
    addAllIfGiven(self._getChangesCode());
    addIfGiven(self._getRetireCode());
    addAllIfGiven(self._getLimitCode());
    addAllIfGiven(self._getRechargeCode());
    addAllIfGiven(self._getRecycleCode());
    addAllIfGiven(self._getReplaceCode());

    addCode("end substance", spaces);
    return finalizeCodePieces(baselinePieces);
  }

  /**
   * Generate code for enable commands.
   *
   * @returns {string[]|null} Array of code strings or null if no enables.
   * @private
   */
  _getEnablesCode() {
    const self = this;
    if (self._enables.length == 0) {
      return null;
    }

    const buildEnable = (enable) => {
      const pieces = [
        "enable",
        enable.getTarget(),
      ];
      self._addDuration(pieces, enable);
      return self._finalizeStatement(pieces);
    };

    return self._enables.map(buildEnable);
  }

  /**
   * Generate code for assume command.
   *
   * @returns {string|null} Code string or null if no assume or if assume is "continued" (no-op).
   * @private
   */
  _getAssumeCode() {
    const self = this;

    if (self._assumeMode === null || self._assumeMode === "continued") {
      return null;
    } else if (self._assumeMode === "no") {
      return "assume no sales";
    } else if (self._assumeMode === "only recharge") {
      return "assume only recharge sales";
    } else {
      throw new Error(
        "Invalid assume mode: " + self._assumeMode + ". " +
        "Expected null, 'continued', 'no', or 'only recharge'.",
      );
    }
  }

  /**
   * Generate code for initial charge commands.
   *
   * @returns {string[]|null} Array of code strings or null if no charges.
   * @private
   */
  _getInitialChargesCode() {
    const self = this;
    if (self._initialCharges === null) {
      return null;
    }

    const buildInitialCharge = (initialCharge) => {
      const engineNumber = initialCharge.getValue();
      const pieces = [
        "initial charge with",
        formatEngineNumber(engineNumber),
        "for",
        initialCharge.getTarget(),
      ];
      self._addDuration(pieces, initialCharge);
      return self._finalizeStatement(pieces);
    };

    return self._initialCharges.map(buildInitialCharge);
  }

  /**
   * Generate code for the equals command.
   *
   * @returns {string|null} Code string or null if no equals command.
   * @private
   */
  _getEqualsCode(equalsCommand) {
    const self = this;
    if (equalsCommand === null) {
      return null;
    }

    const engineNumber = equalsCommand.getValue();
    const pieces = [
      "equals",
      formatEngineNumber(engineNumber),
    ];
    self._addDuration(pieces, equalsCommand);

    return self._finalizeStatement(pieces);
  }

  /**
   * Generate code for set value commands.
   *
   * @returns {string[]|null} Array of code strings or null if no set values.
   * @private
   */
  _getSetValsCode() {
    const self = this;
    if (self._setVals.length == 0) {
      return null;
    }

    const buildSetVal = (setVal) => {
      const engineNumber = setVal.getValue();
      const pieces = [
        "set",
        setVal.getTarget(),
        "to",
        formatEngineNumber(engineNumber),
      ];
      self._addDuration(pieces, setVal);
      return self._finalizeStatement(pieces);
    };

    return self._setVals.map(buildSetVal);
  }

  /**
   * Generate code for change commands.
   *
   * @returns {string[]|null} Array of code strings or null if no changes.
   * @private
   */
  _getChangesCode() {
    const self = this;
    if (self._change === null) {
      return null;
    }

    const buildChange = (change) => {
      const engineNumber = change.getValue();
      const pieces = [
        "change",
        change.getTarget(),
        "by",
        formatEngineNumber(engineNumber),
      ];
      self._addDuration(pieces, change);
      return self._finalizeStatement(pieces);
    };

    return self._changes.map(buildChange);
  }

  /**
   * Generate code for the retire command.
   *
   * @returns {string|null} Code string or null if no retire command.
   * @private
   */
  _getRetireCode() {
    const self = this;
    if (self._retire === null) {
      return null;
    }

    const engineNumber = self._retire.getValue();
    const pieces = [
      "retire",
      formatEngineNumber(engineNumber),
    ];

    // Add "with replacement" if the flag is set (must come before duration)
    if (self._retire.getWithReplacement()) {
      pieces.push("with replacement");
    }

    self._addDuration(pieces, self._retire);

    return self._finalizeStatement(pieces);
  }

  /**
   * Generate code for limit commands.
   *
   * @returns {string[]|null} Array of code strings or null if no limits.
   * @private
   */
  _getLimitCode() {
    const self = this;
    if (self._limits === null || self._limits.length == 0) {
      return null;
    }

    const buildLimit = (limit) => {
      const engineNumber = limit.getValue();
      const pieces = [
        limit.getTypeName(),
        limit.getTarget(),
        "to",
        formatEngineNumber(engineNumber),
      ];

      const displacing = limit.getDisplacing();
      if (displacing !== null && displacing !== undefined) {
        const displacingType = limit.getDisplacingType();

        // Build the displacement clause with proper spacing
        if (displacingType === "by volume") {
          pieces.push("displacing by volume");
        } else if (displacingType === "by units") {
          pieces.push("displacing by units");
        } else {
          pieces.push("displacing");
        }

        pieces.push('"' + displacing + '"');
      }

      self._addDuration(pieces, limit);
      return self._finalizeStatement(pieces);
    };

    return self._limits.map(buildLimit);
  }

  /**
   * Generate code for recharge commands.
   *
   * @returns {string[]|null} Array of code strings or null if no recharge commands.
   * @private
   */
  _getRechargeCode() {
    const self = this;
    if (self._recharges.length == 0) {
      return null;
    }

    return self._recharges.map((recharge) => {
      if (recharge.buildCommand) {
        // New RechargeCommand objects with buildCommand method
        return self._finalizeStatement([recharge.buildCommand()]);
      } else {
        // Legacy Command objects
        const targetEngineNumber = recharge.getTarget();
        const valueEngineNumber = recharge.getValue();
        const pieces = [
          "recharge",
          formatEngineNumber(targetEngineNumber),
          "with",
          formatEngineNumber(valueEngineNumber),
        ];
        self._addDuration(pieces, recharge);
        return self._finalizeStatement(pieces);
      }
    });
  }

  /**
   * Generate code for recycle commands.
   *
   * @returns {string[]|null} Array of code strings or null if no recycles.
   * @private
   */
  _getRecycleCode() {
    const self = this;
    if (self._recycles === null) {
      return null;
    }

    const buildRecycle = (recycle) => {
      const targetEngineNumber = recycle.getTarget();
      const valueEngineNumber = recycle.getValue();
      const pieces = [
        "recover",
        formatEngineNumber(targetEngineNumber),
        "with",
        formatEngineNumber(valueEngineNumber),
        "reuse",
      ];

      // Add induction clause if specified
      const induction = recycle.getInduction ? recycle.getInduction() : null;
      if (induction !== null) {
        pieces.push("with");
        if (induction === "default") {
          pieces.push("default");
        } else if (induction instanceof EngineNumber) {
          pieces.push(formatEngineNumber(induction));
        } else {
          // Handle string numbers or other formats
          pieces.push(induction.toString());
          pieces.push("%");
        }
        pieces.push("induction");
      }

      const stage = recycle.getStage ? recycle.getStage() : "recharge";
      if (stage !== "recharge") {
        pieces.push("at");
        pieces.push(stage);
      }

      self._addDuration(pieces, recycle);

      return self._finalizeStatement(pieces);
    };

    return self._recycles.map(buildRecycle);
  }

  /**
   * Generate code for replace commands.
   *
   * @returns {string[]|null} Array of code strings or null if no replaces.
   * @private
   */
  _getReplaceCode() {
    const self = this;
    if (self._replaces === null) {
      return null;
    }

    const buildReplace = (replace) => {
      const pieces = [
        "replace",
        formatEngineNumber(replace.getVolume()),
        "of",
        replace.getSource(),
        "with",
        '"' + replace.getDestination() + '"',
      ];
      self._addDuration(pieces, replace);

      return self._finalizeStatement(pieces);
    };

    return self._replaces.map(buildReplace);
  }

  /**
   * Adds duration information to code pieces array.
   *
   * @param {string[]} pieces - Array of code pieces to append to.
   * @param {Command} command - Command containing duration info.
   * @private
   */
  _addDuration(pieces, command) {
    const self = this;

    const duration = command.getDuration();
    if (duration === null) {
      return;
    }

    const startYear = duration.getStart();
    const endYear = duration.getEnd();
    if (startYear === null && endYear === null) {
      return;
    }

    if (startYear && endYear && startYear.equals(endYear)) {
      pieces.push("during year " + startYear.getYearStr());
      return;
    }

    const processUnbounded = () => {
      const noStart = startYear === null;
      const startYearRealized = noStart ? "beginning" : startYear.getYearStr();

      const noEnd = endYear === null;
      const endYearRealized = noEnd ? "onwards" : endYear.getYearStr();

      pieces.push("during years " + startYearRealized + " to " + endYearRealized);
    };

    const processBounded = () => {
      const startYearValue = startYear.getNumericYear();
      const endYearValue = endYear.getNumericYear();
      const startYearRearrange = Math.min(startYearValue, endYearValue);
      const endYearRearrange = Math.max(startYearValue, endYearValue);
      pieces.push("during years " + startYearRearrange + " to " + endYearRearrange);
    };

    if (startYear === null || endYear === null) {
      processUnbounded();
    } else {
      processBounded();
    }
  }

  /**
   * Join code pieces into a single statement.
   *
   * @param {string[]} pieces - Array of code pieces to join.
   * @returns {string} The combined code statement.
   * @private
   */
  _finalizeStatement(pieces) {
    const self = this;
    return pieces.map((x) => x + "").join(" ");
  }
}

/**
 * Metadata container for substance properties that can be exported to CSV.
 *
 * This class centralizes all CSV-mappable attributes for a substance, providing
 * a clean interface for metadata manipulation and export/import operations.
 */
class SubstanceMetadata {
  /**
   * Create a new SubstanceMetadata instance.
   *
   * @param {string} substance - Name of the substance without equipment type
   * @param {string} equipment - Optional equipment type (added with " - " separator)
   * @param {string} application - Name of the application that consumes this substance
   * @param {string} ghg - GWP conversion value (e.g., "1430 kgCO2e / kg")
   * @param {boolean} hasDomestic - Whether domestic stream is enabled
   * @param {boolean} hasImport - Whether import stream is enabled
   * @param {boolean} hasExport - Whether export stream is enabled
   * @param {string} energy - Annual energy consumption (e.g., "500 kwh / unit")
   * @param {string} initialChargeDomestic - Initial charge for domestic stream
   * @param {string} initialChargeImport - Initial charge for import stream
   * @param {string} initialChargeExport - Initial charge for export stream
   * @param {string} retirement - Retirement rate (e.g., "10% / year")
   * @param {string} retirementWithReplacement - String "true" or "false" for replacement flag
   * @param {string} defaultSales - Sales assumption mode (user-facing value)
   */
  constructor(
    substance,
    equipment,
    application,
    ghg,
    hasDomestic,
    hasImport,
    hasExport,
    energy,
    initialChargeDomestic,
    initialChargeImport,
    initialChargeExport,
    retirement,
    retirementWithReplacement,
    defaultSales,
  ) {
    const self = this;
    self._substance = substance || "";
    self._equipment = equipment || "";
    self._application = application || "";
    self._ghg = ghg || "";
    self._hasDomestic = hasDomestic || false;
    self._hasImport = hasImport || false;
    self._hasExport = hasExport || false;
    self._energy = energy || "";
    self._initialChargeDomestic = initialChargeDomestic || "";
    self._initialChargeImport = initialChargeImport || "";
    self._initialChargeExport = initialChargeExport || "";
    self._retirement = retirement || "";
    self._retirementWithReplacement = retirementWithReplacement || "";
    self._defaultSales = defaultSales || "";
  }

  /**
   * Get the substance name (without equipment type).
   *
   * @returns {string} The substance name.
   */
  getSubstance() {
    const self = this;
    return self._substance;
  }

  /**
   * Get the equipment type.
   *
   * @returns {string} The equipment type.
   */
  getEquipment() {
    const self = this;
    return self._equipment;
  }

  /**
   * Get the effective name combining substance and equipment type.
   *
   * Handles concatenation logic similar to getEffectiveName in ui_editor.js.
   * If equipment type is empty, returns just the substance name.
   * Otherwise, returns "substance - equipment".
   *
   * @returns {string} The effective name for the substance.
   */
  getName() {
    const self = this;
    if (!self._equipment || self._equipment.trim() === "") {
      return self._substance;
    } else {
      return self._substance + " - " + self._equipment;
    }
  }

  /**
   * Get the CSV key format used by ConsumptionListPresenter.
   *
   * Returns the format: "substanceName" for "applicationName"
   *
   * @returns {string} The key format for CSV operations.
   */
  getKey() {
    const self = this;
    return '"' + self.getName() + '" for "' + self._application + '"';
  }

  /**
   * Get the application name.
   *
   * @returns {string} The application name.
   */
  getApplication() {
    const self = this;
    return self._application;
  }

  /**
   * Get the GHG equivalency value.
   *
   * @returns {string} The GHG value.
   */
  getGhg() {
    const self = this;
    return self._ghg;
  }

  /**
   * Check if domestic stream is enabled.
   *
   * @returns {boolean} True if domestic stream is enabled.
   */
  getHasDomestic() {
    const self = this;
    return self._hasDomestic;
  }

  /**
   * Check if import stream is enabled.
   *
   * @returns {boolean} True if import stream is enabled.
   */
  getHasImport() {
    const self = this;
    return self._hasImport;
  }

  /**
   * Check if export stream is enabled.
   *
   * @returns {boolean} True if export stream is enabled.
   */
  getHasExport() {
    const self = this;
    return self._hasExport;
  }

  /**
   * Get the energy consumption value.
   *
   * @returns {string} The energy consumption value.
   */
  getEnergy() {
    const self = this;
    return self._energy;
  }

  /**
   * Get the initial charge for domestic stream.
   *
   * @returns {string} The initial charge value.
   */
  getInitialChargeDomestic() {
    const self = this;
    return self._initialChargeDomestic;
  }

  /**
   * Get the initial charge for import stream.
   *
   * @returns {string} The initial charge value.
   */
  getInitialChargeImport() {
    const self = this;
    return self._initialChargeImport;
  }

  /**
   * Get the initial charge for export stream.
   *
   * @returns {string} The initial charge value.
   */
  getInitialChargeExport() {
    const self = this;
    return self._initialChargeExport;
  }

  /**
   * Get the retirement rate.
   *
   * @returns {string} The retirement rate.
   */
  getRetirement() {
    const self = this;
    return self._retirement;
  }

  /**
   * Get the retirement with replacement flag.
   *
   * @returns {string} String "true" or "false", or empty string if not set.
   */
  getRetirementWithReplacement() {
    const self = this;
    return self._retirementWithReplacement;
  }

  /**
   * Get the default sales assumption mode.
   *
   * @returns {string} The default sales mode (user-facing value).
   */
  getDefaultSales() {
    const self = this;
    return self._defaultSales;
  }
}

/**
 * Builder for constructing SubstanceMetadata instances with fluent interface.
 *
 * Provides method chaining for easy construction of metadata objects.
 */
class SubstanceMetadataBuilder {
  /**
   * Create a new SubstanceMetadataBuilder.
   */
  constructor() {
    const self = this;
    self._substance = null;
    self._equipment = null;
    self._application = null;
    self._ghg = null;
    self._hasDomestic = false;
    self._hasImport = false;
    self._hasExport = false;
    self._energy = null;
    self._initialChargeDomestic = null;
    self._initialChargeImport = null;
    self._initialChargeExport = null;
    self._retirement = null;
    self._retirementWithReplacement = null;
    self._defaultSales = null;
  }

  /**
   * Set the substance name.
   *
   * @param {string} substance - The substance name.
   * @returns {SubstanceMetadataBuilder} This builder instance for method chaining.
   */
  setSubstance(substance) {
    const self = this;
    self._substance = substance;
    return self;
  }

  /**
   * Set the equipment type.
   *
   * @param {string} equipment - The equipment type.
   * @returns {SubstanceMetadataBuilder} This builder instance for method chaining.
   */
  setEquipment(equipment) {
    const self = this;
    self._equipment = equipment;
    return self;
  }

  /**
   * Set the application name.
   *
   * @param {string} application - The application name.
   * @returns {SubstanceMetadataBuilder} This builder instance for method chaining.
   */
  setApplication(application) {
    const self = this;
    self._application = application;
    return self;
  }

  /**
   * Set the GHG equivalency value.
   *
   * @param {string} ghg - The GHG value.
   * @returns {SubstanceMetadataBuilder} This builder instance for method chaining.
   */
  setGhg(ghg) {
    const self = this;
    self._ghg = ghg;
    return self;
  }

  /**
   * Set whether domestic stream is enabled.
   *
   * @param {boolean} hasDomestic - True if domestic stream is enabled.
   * @returns {SubstanceMetadataBuilder} This builder instance for method chaining.
   */
  setHasDomestic(hasDomestic) {
    const self = this;
    self._hasDomestic = hasDomestic || false;
    return self;
  }

  /**
   * Set whether import stream is enabled.
   *
   * @param {boolean} hasImport - True if import stream is enabled.
   * @returns {SubstanceMetadataBuilder} This builder instance for method chaining.
   */
  setHasImport(hasImport) {
    const self = this;
    self._hasImport = hasImport || false;
    return self;
  }

  /**
   * Set whether export stream is enabled.
   *
   * @param {boolean} hasExport - True if export stream is enabled.
   * @returns {SubstanceMetadataBuilder} This builder instance for method chaining.
   */
  setHasExport(hasExport) {
    const self = this;
    self._hasExport = hasExport || false;
    return self;
  }

  /**
   * Set the energy consumption value.
   *
   * @param {string} energy - The energy consumption value.
   * @returns {SubstanceMetadataBuilder} This builder instance for method chaining.
   */
  setEnergy(energy) {
    const self = this;
    self._energy = energy;
    return self;
  }

  /**
   * Set the initial charge for domestic stream.
   *
   * @param {string} initialChargeDomestic - The initial charge value.
   * @returns {SubstanceMetadataBuilder} This builder instance for method chaining.
   */
  setInitialChargeDomestic(initialChargeDomestic) {
    const self = this;
    self._initialChargeDomestic = initialChargeDomestic;
    return self;
  }

  /**
   * Set the initial charge for import stream.
   *
   * @param {string} initialChargeImport - The initial charge value.
   * @returns {SubstanceMetadataBuilder} This builder instance for method chaining.
   */
  setInitialChargeImport(initialChargeImport) {
    const self = this;
    self._initialChargeImport = initialChargeImport;
    return self;
  }

  /**
   * Set the initial charge for export stream.
   *
   * @param {string} initialChargeExport - The initial charge value.
   * @returns {SubstanceMetadataBuilder} This builder instance for method chaining.
   */
  setInitialChargeExport(initialChargeExport) {
    const self = this;
    self._initialChargeExport = initialChargeExport;
    return self;
  }

  /**
   * Set the retirement rate.
   *
   * @param {string} retirement - The retirement rate.
   * @returns {SubstanceMetadataBuilder} This builder instance for method chaining.
   */
  setRetirement(retirement) {
    const self = this;
    self._retirement = retirement;
    return self;
  }

  /**
   * Set the retirement with replacement flag.
   *
   * @param {string} retirementWithReplacement - String "true" or "false".
   * @returns {SubstanceMetadataBuilder} This builder instance for method chaining.
   */
  setRetirementWithReplacement(retirementWithReplacement) {
    const self = this;
    self._retirementWithReplacement = retirementWithReplacement;
    return self;
  }

  /**
   * Set the default sales assumption mode.
   *
   * @param {string} defaultSales - The default sales mode (user-facing value).
   * @returns {SubstanceMetadataBuilder} This builder instance for method chaining.
   */
  setDefaultSales(defaultSales) {
    const self = this;
    self._defaultSales = defaultSales;
    return self;
  }

  /**
   * Build a SubstanceMetadata instance from current builder state.
   *
   * @returns {SubstanceMetadata} The constructed metadata instance.
   * @throws {Error} If required fields are null or empty.
   */
  build() {
    const self = this;

    // Validate required fields are non-null
    if (self._substance === null || self._substance === undefined) {
      throw new Error("Substance name is required");
    }
    if (self._application === null || self._application === undefined) {
      throw new Error("Application name is required");
    }

    // Convert null values to empty strings for optional fields to maintain compatibility
    return new SubstanceMetadata(
      self._substance,
      self._equipment || "",
      self._application,
      self._ghg || "",
      self._hasDomestic,
      self._hasImport,
      self._hasExport,
      self._energy || "",
      self._initialChargeDomestic || "",
      self._initialChargeImport || "",
      self._initialChargeExport || "",
      self._retirement || "",
      self._retirementWithReplacement || "",
      self._defaultSales || "",
    );
  }
}

export {
  AboutStanza,
  Application,
  AssumeCommand,
  Command,
  DefinitionalStanza,
  IncompatibleCommand,
  LimitCommand,
  Program,
  RechargeCommand,
  RecycleCommand,
  ReplaceCommand,
  RetireCommand,
  SimulationScenario,
  SimulationStanza,
  Substance,
  SubstanceBuilder,
  SubstanceMetadata,
  SubstanceMetadataBuilder,
};