Source: report_data.js

/**
 * Data structures for report and visualization functionality.
 *
 * @license BSD, see LICENSE.md.
 */

import {EngineNumber, makeNumberUnambiguousString} from "engine_number";
import {AggregatedResult, AttributeToExporterResult} from "engine_struct";

/**
 * Builder class for creating metric computation strategies.
 *
 * Builder which handles the construction of metric processing pipelines
 * including transformations and unit conversions.
 */
class MetricStrategyBuilder {
  /**
   * Create a new MetricStrategyBuilder instance.
   *
   * Create a new MetricStrategyBuilder instance, initalizing all strategy
   * components to null requiring them to be specified later.
   */
  constructor() {
    const self = this;
    self._strategies = {};
    self._metric = null;
    self._submetric = null;
    self._units = null;
    self._strategy = null;
    self._transformation = null;
  }

  /**
   * Set the metric name for the strategy.
   *
   * @param {string} metric - The metric name (e.g., 'sales', 'emissions').
   */
  setMetric(metric) {
    const self = this;
    self._metric = metric;
  }

  /**
   * Set the submetric name for the strategy.
   *
   * @param {string} submetric - The submetric name (e.g., 'all', 'import').
   */
  setSubmetric(submetric) {
    const self = this;
    self._submetric = submetric;
  }

  /**
   * Set the units for the strategy output.
   *
   * @param {string} units - The units specification (e.g., 'MtCO2e / yr').
   */
  setUnits(units) {
    const self = this;
    self._units = units;
  }

  /**
   * Set the core computation strategy.
   *
   * @param {Function} strategy - The function that implements the core metric
   *     computation.
   */
  setStrategy(strategy) {
    const self = this;
    self._strategy = strategy;
  }

  /**
   * Set the transformation to apply after the core strategy.
   *
   * @param {Function} transformation - The function that transforms the strategy
   *     output.
   */
  setTransformation(transformation) {
    const self = this;
    self._transformation = transformation;
  }

  /**
   * Add the configured strategy to the strategies collection.
   *
   * @throws {string} If any required component is null.
   */
  add() {
    const self = this;
    self._requireCompleteDefinition();

    const fullNamePieces = [self._metric, self._submetric, self._units];
    const fullName = fullNamePieces.join(":");

    const innerStrategy = self._strategy;
    const innerTransformation = self._transformation;
    const execute = (filterSet) => {
      const result = innerStrategy(filterSet);

      if (result === null) {
        return null;
      }

      const transformed = innerTransformation(result);
      return transformed;
    };

    self._strategies[fullName] = execute;

    // Also register "/ year" variant if "/ yr" key exists
    if (fullName.includes("/ yr")) {
      const yearVariant = fullName.replace("/ yr", "/ year");
      self._strategies[yearVariant] = execute;
    }
  }

  /**
   * Build and return the complete strategies object.
   * @returns {Object} The strategies object containing all added strategies.
   */
  build() {
    const self = this;
    return self._strategies;
  }

  /**
   * Verify that all required components have been set.
   * @private
   * @throws {string} If any required component is null.
   */
  _requireCompleteDefinition() {
    const self = this;
    const pieces = [
      self._metric,
      self._submetric,
      self._units,
      self._strategy,
      self._transformation,
    ];
    const nullPieces = pieces.filter((x) => x === null);
    const numNullPieces = nullPieces.map((x) => 1).reduce((a, b) => a + b, 0);
    if (numNullPieces > 0) {
      throw "Encountered null values on MetricStrategyBuilder";
    }
  }
}

/**
 * Facade which simplifies access to engine outputs.
 *
 * Wrapper class for report data that provides filtering and aggregation
 * capabilities over simplified engine outputs.
 */
class ReportDataWrapper {
  /**
   * Create a new report data wrapper.
   *
   * @param {Array<EngineResult>} innerData - The raw report data to wrap.
   */
  constructor(innerData) {
    const self = this;
    self._innerData = innerData;
    self._innerDataExporterAttributed = null;

    const strategyBuilder = new MetricStrategyBuilder();

    self._addEmissionsStrategies(strategyBuilder);
    self._addSalesStrategies(strategyBuilder);
    self._addConsumptionStrategies(strategyBuilder);
    self._addPopulationStrategies(strategyBuilder);
    self._addBankStrategies(strategyBuilder);

    self._metricStrategies = strategyBuilder.build();
  }

  /**
   * Get the raw underlying data.
   *
   * @param {FilterSet} filterSet - Filter set with attribution settings to
   *     apply.
   * @returns {Array<EngineResult>} The raw data.
   */
  getRawData(filterSet) {
    const self = this;
    if (filterSet.getAttributeImporter()) {
      return self._innerData;
    } else {
      if (self._innerDataExporterAttributed === null) {
        self._innerDataExporterAttributed = self._buildExporterAttributed(self._innerData);
      }
      return self._innerDataExporterAttributed;
    }
  }

  /**
   * Get metric value based on filter set.
   *
   * @param {FilterSet} filterSet - The filter criteria to apply.
   * @returns {EngineNumber} The filtered metric value.
   */
  getMetric(filterSet) {
    const self = this;
    const metric = filterSet.getFullMetricName();
    const metricStrategy = self._metricStrategies[metric];

    // Check if metricStrategy exists and is a function
    if (typeof metricStrategy !== "function") {
      console.warn("MetricStrategy is not a function for metric:", metric);
      // Trigger global error recovery if available
      const kigaliAppFound = window.kigaliApp;
      const resetAvailable = kigaliAppFound &&
        typeof kigaliAppFound.resetVisualizationState === "function";
      if (resetAvailable) {
        kigaliAppFound.resetVisualizationState();
      }
      return null;
    }

    try {
      const value = metricStrategy(filterSet);
      return value;
    } catch (error) {
      // Handle "metricStrategy is not a function" and other strategy execution errors
      if (self._isMetricStrategyError(error)) {
        console.warn("MetricStrategy execution failed - not a function:", error.message);
        // Trigger global error recovery if available
        const kigaliAppFound = window.kigaliApp;
        const resetAvailable = kigaliAppFound &&
          typeof kigaliAppFound.resetVisualizationState === "function";
        if (resetAvailable) {
          kigaliAppFound.resetVisualizationState();
        }
        return null;
      } else {
        // Re-throw other errors as they may be legitimate validation errors
        throw error;
      }
    }
  }

  /**
   * Get dimension values based on a given filter set.
   *
   * @param {FilterSet} filterSet - The filter criteria to apply.
   * @returns {Set<*>} Set of dimension values.
   */
  getDimensionValues(filterSet) {
    const self = this;
    const dimension = filterSet.getDimension();
    const strategy = {
      simulations: () => self.getScenarios(filterSet),
      applications: () => self.getApplications(filterSet),
      substances: () => self.getSubstances(filterSet),
    }[dimension];
    const value = strategy();
    return value;
  }

  /**
   * Get scenarios matching a given filter set.
   *
   * @param {FilterSet} filterSet - The filter criteria to apply.
   * @returns {Set<string>} Set of scenario names.
   */
  getScenarios(filterSet) {
    const self = this;
    if (filterSet.getScenario() === null) {
      return new Set(self.getRawData(filterSet).map((x) => x.getScenarioName()));
    } else {
      return new Set([filterSet.getScenario()]);
    }
  }

  /**
   * Get the name of the first scenario available without applying any filter.
   *
   * @param {FilterSet} filterSet - The filter set indicating preprocessing
   *    options even though a filter is not applied.
   * @returns {string|null} The first scenario name or null if no scenarios
   *    present.
   */
  getFirstScenario(filterSet) {
    const self = this;
    const scenarios = self.getScenarios(filterSet);
    for (const scenario of scenarios) {
      return scenario;
    }
    return null;
  }

  /**
   * Get applications matching a given filter set.
   *
   * @param {FilterSet} filterSet - The filter criteria to apply.
   * @returns {Set<string>} Set of application names.
   */
  getApplications(filterSet) {
    const self = this;
    return self._getSetAfterFilter(filterSet, (x) => x.getApplication());
  }

  /**
   * Get substances matching a given filter set.
   *
   * @param {FilterSet} filterSet - The filter criteria to apply.
   * @returns {Set<string>} Set of substance names.
   */
  getSubstances(filterSet) {
    const self = this;
    return self._getSetAfterFilter(filterSet, (x) => x.getSubstance());
  }

  /**
   * Get years matching a given filter set.
   *
   * @param {FilterSet} filterSet - The filter criteria to apply.
   * @returns {Set<number>} Set of years.
   */
  getYears(filterSet) {
    const self = this;
    return self._getSetAfterFilter(filterSet, (x) => x.getYear());
  }

  /**
   * Get consumption value matching a given filter set.
   *
   * @param {FilterSet} filterSet - The filter criteria to apply.
   * @returns {EngineNumber|null} The consumption value, or null if no matching
   *     results.
   */
  getGhgConsumption(filterSet) {
    const self = this;
    const aggregated = self._getAggregatedAfterFilter(filterSet);
    return aggregated === null ? null : aggregated.getGhgConsumption();
  }

  /**
   * Get the domestic consumption value matching a given filter set.
   *
   * @param {FilterSet} filterSet - The filter criteria to apply.
   * @returns {EngineNumber|null} The domestic consumption value, or null if no
   *     matching results.
   */
  getDomesticConsumption(filterSet) {
    const self = this;
    const aggregated = self._getAggregatedAfterFilter(filterSet);
    return aggregated === null ? null : aggregated.getDomesticConsumption();
  }

  /**
   * Get the import consumption value matching a given filter set.
   *
   * @param {FilterSet} filterSet - The filter criteria to apply.
   * @returns {EngineNumber|null} The import consumption value, or null if no
   *     matching results.
   */
  getImportConsumption(filterSet) {
    const self = this;
    const aggregated = self._getAggregatedAfterFilter(filterSet);
    return aggregated === null ? null : aggregated.getImportConsumption();
  }

  /**
   * Get the recycled consumption value matching a given filter set.
   *
   * @param {FilterSet} filterSet - The filter criteria to apply.
   * @returns {EngineNumber|null} The recycled consumption value, or null if no
   *     matching results.
   */
  getRecycleConsumption(filterSet) {
    const self = this;
    const aggregated = self._getAggregatedAfterFilter(filterSet);
    return aggregated === null ? null : aggregated.getRecycleConsumption();
  }

  /**
   * Get export consumption value matching a given filter set.
   *
   * @param {FilterSet} filterSet - The filter criteria to apply.
   * @returns {EngineNumber|null} The export consumption value, or null if no matching
   *     results.
   */
  getExportConsumption(filterSet) {
    const self = this;
    const aggregated = self._getAggregatedAfterFilter(filterSet);
    return aggregated === null ? null : aggregated.getExportConsumption();
  }

  /**
   * Get total emissions value matching a given filter set.
   *
   * @param {FilterSet} filterSet - The filter criteria to apply.
   * @returns {EngineNumber|null} The total emissions value, or null if no matching
   *     results.
   */
  getTotalEmissions(filterSet) {
    const self = this;
    const aggregated = self._getAggregatedAfterFilter(filterSet);
    return aggregated === null ? null : aggregated.getTotalEmissions();
  }

  /**
   * Get recharge emissions value matching a given filter set.
   *
   * @param {FilterSet} filterSet - The filter criteria to apply.
   * @returns {EngineNumber|null} The recharge emissions value, or null if no
   *     matching results.
   */
  getRechargeEmissions(filterSet) {
    const self = this;
    const aggregated = self._getAggregatedAfterFilter(filterSet);
    return aggregated === null ? null : aggregated.getRechargeEmissions();
  }

  /**
   * Get end-of-life emissions value matching a given filter set.
   *
   * @param {FilterSet} filterSet - The filter criteria to apply.
   * @returns {EngineNumber|null} The end-of-life emissions value, or null if
   *     no matching results.
   */
  getEolEmissions(filterSet) {
    const self = this;
    const aggregated = self._getAggregatedAfterFilter(filterSet);
    return aggregated === null ? null : aggregated.getEolEmissions();
  }

  /**
   * Get export emissions value matching a given filter set.
   *
   * @param {FilterSet} filterSet - The filter criteria to apply.
   * @returns {EngineNumber|null} The export emissions value, or null if no matching
   *     results.
   */
  getExportEmissions(filterSet) {
    const self = this;
    const aggregated = self._getAggregatedAfterFilter(filterSet);
    return aggregated === null ? null : aggregated.getExportConsumption();
  }

  /**
   * Get initial charge emissions value matching a given filter set.
   *
   * This is an informational metric representing the GHG potential of substance
   * initially charged into equipment. Actual emissions occur later during recharge
   * (leakage between servicings) or at end-of-life disposal.
   *
   * @param {FilterSet} filterSet - The filter criteria to apply.
   * @returns {EngineNumber|null} The initial charge emissions value, or null if no matching
   *     results.
   */
  getInitialChargeEmissions(filterSet) {
    const self = this;
    const aggregated = self._getAggregatedAfterFilter(filterSet);
    return aggregated === null ? null : aggregated.getInitialChargeEmissions();
  }

  /**
   * Get sales value matching a given filter set.
   *
   * @param {FilterSet} filterSet - The filter criteria to apply.
   * @returns {EngineNumber|null} The sales value, or null if no matching
   *     results.
   */
  getSales(filterSet) {
    const self = this;
    const aggregated = self._getAggregatedAfterFilter(filterSet);
    return aggregated === null ? null : aggregated.getSales();
  }

  /**
   * Get sales from imports matching a given filter set.
   *
   * @param {FilterSet} filterSet - The filter criteria to apply.
   * @returns {EngineNumber|null} The imports component of sales, or null if no
   *     matching results.
   */
  getImport(filterSet) {
    const self = this;
    const aggregated = self._getAggregatedAfterFilter(filterSet);
    return aggregated === null ? null : aggregated.getImport();
  }

  /**
   * Get sales from domestic manufacture matching a given filter set.
   *
   * @param {FilterSet} filterSet - The filter criteria to apply.
   * @returns {EngineNumber|null} The domestic manufacture component of sales,
   *     or null if no matching results.
   */
  getDomestic(filterSet) {
    const self = this;
    const aggregated = self._getAggregatedAfterFilter(filterSet);
    return aggregated === null ? null : aggregated.getDomestic();
  }

  /**
   * Get the recycled sales value matching a given filter set.
   *
   * @param {FilterSet} filterSet - The filter criteria to apply.
   * @returns {EngineNumber|null} The recycled sales value, or null if no
   *     matching results.
   */
  getRecycle(filterSet) {
    const self = this;
    const aggregated = self._getAggregatedAfterFilter(filterSet);
    return aggregated === null ? null : aggregated.getRecycle();
  }

  /**
   * Get export value matching a given filter set.
   *
   * @param {FilterSet} filterSet - The filter criteria to apply.
   * @returns {EngineNumber|null} The export value, or null if no matching
   *     results.
   */
  getExport(filterSet) {
    const self = this;
    const aggregated = self._getAggregatedAfterFilter(filterSet);
    return aggregated === null ? null : aggregated.getExport();
  }

  /**
   * Get population value matching a given filter set.
   *
   * @param {FilterSet} filterSet - The filter criteria to apply.
   * @returns {EngineNumber|null} The population value, or null if no matching
   *     results.
   */
  getPopulation(filterSet) {
    const self = this;
    const aggregated = self._getAggregatedAfterFilter(filterSet);
    return aggregated === null ? null : aggregated.getPopulation();
  }

  /**
   * Get the amount of new equipment added.
   *
   * @param {FilterSet} filterSet - The filter criteria to apply.
   * @returns {EngineNumber|null} The new equipment added, or null if no matching
   *     results.
   */
  getPopulationNew(filterSet) {
    const self = this;
    const aggregated = self._getAggregatedAfterFilter(filterSet);
    return aggregated === null ? null : aggregated.getPopulationNew();
  }

  /**
   * Get energy consumption value matching a given filter set.
   *
   * @param {FilterSet} filterSet - The filter criteria to apply.
   * @returns {EngineNumber|null} The energy consumption value, or null if no
   *     matching results.
   */
  getEnergyConsumption(filterSet) {
    const self = this;
    const aggregated = self._getAggregatedAfterFilter(filterSet);
    return aggregated === null ? null : aggregated.getEnergyConsumption();
  }

  /**
   * Get bank kg value matching a given filter set.
   *
   * @param {FilterSet} filterSet - The filter criteria to apply.
   * @returns {EngineNumber|null} The bank kg value, or null if no matching
   *     results.
   */
  getBankKg(filterSet) {
    const self = this;
    const aggregated = self._getAggregatedAfterFilter(filterSet);
    return aggregated === null ? null : aggregated.getBankKg();
  }

  /**
   * Get bank tCO2e value matching a given filter set.
   *
   * @param {FilterSet} filterSet - The filter criteria to apply.
   * @returns {EngineNumber|null} The bank tCO2e value, or null if no matching
   *     results.
   */
  getBankTco2e(filterSet) {
    const self = this;
    const aggregated = self._getAggregatedAfterFilter(filterSet);
    return aggregated === null ? null : aggregated.getBankTco2e();
  }

  /**
   * Get bank change kg value matching a given filter set.
   *
   * @param {FilterSet} filterSet - The filter criteria to apply.
   * @returns {EngineNumber|null} The bank change kg value, or null if no matching
   *     results.
   */
  getBankChangeKg(filterSet) {
    const self = this;
    const aggregated = self._getAggregatedAfterFilter(filterSet);
    return aggregated === null ? null : aggregated.getBankChangeKg();
  }

  /**
   * Get bank change tCO2e value matching a given filter set.
   *
   * @param {FilterSet} filterSet - The filter criteria to apply.
   * @returns {EngineNumber|null} The bank change tCO2e value, or null if no matching
   *     results.
   */
  getBankChangeTco2e(filterSet) {
    const self = this;
    const aggregated = self._getAggregatedAfterFilter(filterSet);
    return aggregated === null ? null : aggregated.getBankChangeTco2e();
  }

  /**
   * Normalize time units by removing " / year" and " / yr" suffixes.
   *
   * @private
   * @param {string} units - The units string to normalize.
   * @returns {string} The normalized units string without time suffixes.
   */
  _normalizeTimeUnits(units) {
    const self = this;
    return units
      .replace(" / year", "")
      .replace(" / yr", "");
  }

  /**
   * Validate that emissions units are in expected format.
   *
   * Verifies that the provided value has units in tCO2e format after time unit
   * normalization, throwing an error if units are unexpected.
   *
   * @private
   * @param {EngineNumber} val - The value to validate.
   * @throws {string} If units are not tCO2e.
   */
  _validateEmissionsUnits(val) {
    const self = this;
    const normalizedUnits = self._normalizeTimeUnits(val.getUnits());
    if (normalizedUnits !== "tCO2e") {
      throw "Unexpected emissions source units: " + val.getUnits();
    }
  }

  /**
   * Add emissions conversion strategies to the builder.
   *
   * Adds unit conversion strategies for emissions data including tCO2e, ktCO2e,
   * MtCO2e, and kgCO2e scales with appropriate validation.
   *
   * @private
   * @param {MetricStrategyBuilder} strategyBuilder - The strategy builder to
   *     configure.
   */
  _addEmissionsConversion(strategyBuilder) {
    const self = this;

    // tCO2e scale conversions
    strategyBuilder.setUnits("tCO2e / yr");
    strategyBuilder.setTransformation((val) => {
      self._validateEmissionsUnits(val);
      return new EngineNumber(
        val.getValue(), "tCO2e / yr", makeNumberUnambiguousString(val.getValue()),
      );
    });
    strategyBuilder.add();

    strategyBuilder.setUnits("ktCO2e / yr");
    strategyBuilder.setTransformation((val) => {
      self._validateEmissionsUnits(val);
      const convertedValue = val.getValue() / 1000;
      return new EngineNumber(
        convertedValue, "ktCO2e / yr", makeNumberUnambiguousString(convertedValue),
      );
    });
    strategyBuilder.add();

    strategyBuilder.setUnits("MtCO2e / yr");
    strategyBuilder.setTransformation((val) => {
      self._validateEmissionsUnits(val);
      const convertedValue = val.getValue() / 1000000;
      return new EngineNumber(
        convertedValue, "MtCO2e / yr", makeNumberUnambiguousString(convertedValue),
      );
    });
    strategyBuilder.add();

    // kgCO2e scale conversions
    strategyBuilder.setUnits("kgCO2e / yr");
    strategyBuilder.setTransformation((val) => {
      self._validateEmissionsUnits(val);
      const convertedValue = val.getValue() * 1000;
      return new EngineNumber(
        convertedValue, "kgCO2e / yr", makeNumberUnambiguousString(convertedValue),
      );
    });
    strategyBuilder.add();
  }

  /**
   * Add emissions strategies to the builder.
   *
   * Adds emission metrics including recharge, eol, export, initial charge, and
   * custom emissions with appropriate unit conversions.
   *
   * @private
   * @param {MetricStrategyBuilder} strategyBuilder - The strategy builder to
   *     configure.
   */
  _addEmissionsStrategies(strategyBuilder) {
    const self = this;
    strategyBuilder.setMetric("emissions");

    strategyBuilder.setSubmetric("recharge");
    strategyBuilder.setStrategy((x) => self.getRechargeEmissions(x));
    self._addEmissionsConversion(strategyBuilder);

    strategyBuilder.setSubmetric("eol");
    strategyBuilder.setStrategy((x) => self.getEolEmissions(x));
    self._addEmissionsConversion(strategyBuilder);

    strategyBuilder.setSubmetric("export");
    strategyBuilder.setStrategy((x) => self.getExportEmissions(x));
    self._addEmissionsConversion(strategyBuilder);

    strategyBuilder.setSubmetric("initial charge");
    strategyBuilder.setStrategy((x) => self.getInitialChargeEmissions(x));
    self._addEmissionsConversion(strategyBuilder);

    strategyBuilder.setSubmetric("custom");
    strategyBuilder.setStrategy((filterSet) => {
      const customDef = filterSet.getCustomDefinition("emissions");
      if (!customDef || customDef.length === 0) {
        return null;
      }

      // Map submetrics to their raw emission methods
      const emissionMethods = {
        "recharge": (x) => self.getRechargeEmissions(x),
        "eol": (x) => self.getEolEmissions(x),
        "export": (x) => self.getExportEmissions(x),
        "initial charge": (x) => self.getInitialChargeEmissions(x),
      };

      const results = customDef.map((submetric) => {
        const method = emissionMethods[submetric];
        return method ? method(filterSet) : null;
      }).filter((result) => result !== null);

      if (results.length === 0) {
        return null;
      }

      return results.reduce((a, b) => {
        if (!a) {
          return b;
        }
        if (!b) {
          return a;
        }
        if (a.getUnits() !== b.getUnits()) {
          throw new Error(
            `Cannot combine incompatible units: ${a.getUnits()} and ${b.getUnits()}`,
          );
        }
        const result = a.getValue() + b.getValue();
        return new EngineNumber(result, a.getUnits(), makeNumberUnambiguousString(result));
      });
    });
    self._addEmissionsConversion(strategyBuilder);
  }

  /**
   * Validate that sales units are in expected format.
   *
   * Verifies that the provided value has units in kg format after time unit
   * normalization, throwing an error if units are unexpected.
   *
   * @private
   * @param {EngineNumber} value - The value to validate.
   * @throws {string} If units are not kg.
   */
  _validateSalesUnits(value) {
    const self = this;
    const normalizedUnits = self._normalizeTimeUnits(value.getUnits());
    if (normalizedUnits !== "kg") {
      throw "Unexpected sales units: " + value.getUnits();
    }
  }

  /**
   * Add kg and mt unit conversions to the builder.
   *
   * Creates strategies for converting sales data between kilogram and metric tonne
   * units with validation.
   *
   * @private
   * @param {MetricStrategyBuilder} strategyBuilder - The strategy builder to
   *     configure.
   */
  _makeForKgAndMt(strategyBuilder) {
    const self = this;

    strategyBuilder.setUnits("mt / yr");
    strategyBuilder.setTransformation((value) => {
      self._validateSalesUnits(value);
      const convertedValue = value.getValue() / 1000;
      return new EngineNumber(
        convertedValue, "mt / yr", makeNumberUnambiguousString(convertedValue),
      );
    });
    strategyBuilder.add();

    strategyBuilder.setUnits("kg / yr");
    strategyBuilder.setTransformation((value) => {
      self._validateSalesUnits(value);
      return new EngineNumber(
        value.getValue(), "kg / yr", makeNumberUnambiguousString(value.getValue()),
      );
    });
    strategyBuilder.add();
  }

  /**
   * Add sales strategies to the builder.
   *
   * Adds sales metrics including import, domestic, recycle, export, and custom
   * sales with appropriate unit conversions.
   *
   * @private
   * @param {MetricStrategyBuilder} strategyBuilder - The strategy builder to
   *     configure.
   */
  _addSalesStrategies(strategyBuilder) {
    const self = this;
    strategyBuilder.setMetric("sales");

    strategyBuilder.setSubmetric("import");
    strategyBuilder.setStrategy((x) => self.getImport(x));
    self._makeForKgAndMt(strategyBuilder);

    strategyBuilder.setSubmetric("domestic");
    strategyBuilder.setStrategy((x) => self.getDomestic(x));
    self._makeForKgAndMt(strategyBuilder);

    strategyBuilder.setSubmetric("recycle");
    strategyBuilder.setStrategy((x) => self.getRecycle(x));
    self._makeForKgAndMt(strategyBuilder);

    strategyBuilder.setSubmetric("export");
    strategyBuilder.setStrategy((x) => self.getExport(x));
    self._makeForKgAndMt(strategyBuilder);

    strategyBuilder.setSubmetric("custom");
    strategyBuilder.setStrategy((filterSet) => {
      const customDef = filterSet.getCustomDefinition("sales");
      if (!customDef || customDef.length === 0) {
        return null;
      }

      // Map submetrics to their raw sales methods
      const salesMethods = {
        "domestic": (x) => self.getDomestic(x),
        "import": (x) => self.getImport(x),
        "export": (x) => self.getExport(x),
        "recycle": (x) => self.getRecycle(x),
      };

      const results = customDef.map((submetric) => {
        const method = salesMethods[submetric];
        return method ? method(filterSet) : null;
      }).filter((result) => result !== null);

      if (results.length === 0) {
        return null;
      }

      return results.reduce((a, b) => {
        if (!a) {
          return b;
        }
        if (!b) {
          return a;
        }
        if (a.getUnits() !== b.getUnits()) {
          throw new Error(
            `Cannot combine incompatible units: ${a.getUnits()} and ${b.getUnits()}`,
          );
        }
        const result = a.getValue() + b.getValue();
        return new EngineNumber(result, a.getUnits(), makeNumberUnambiguousString(result));
      });
    });
    self._makeForKgAndMt(strategyBuilder);
  }

  /**
   * Add consumption strategies to the builder.
   *
   * Adds consumption metrics including import, domestic, recycle, export, and
   * custom consumption with appropriate unit conversions.
   *
   * @private
   * @param {MetricStrategyBuilder} strategyBuilder - The strategy builder to
   *     configure.
   */
  _addConsumptionStrategies(strategyBuilder) {
    const self = this;
    strategyBuilder.setMetric("sales");

    strategyBuilder.setSubmetric("import");
    strategyBuilder.setStrategy((x) => self.getImportConsumption(x));
    self._addEmissionsConversion(strategyBuilder);

    strategyBuilder.setSubmetric("domestic");
    strategyBuilder.setStrategy((x) => self.getDomesticConsumption(x));
    self._addEmissionsConversion(strategyBuilder);

    strategyBuilder.setSubmetric("recycle");
    strategyBuilder.setStrategy((x) => self.getRecycleConsumption(x));
    self._addEmissionsConversion(strategyBuilder);

    strategyBuilder.setSubmetric("export");
    strategyBuilder.setStrategy((x) => self.getExportConsumption(x));
    self._addEmissionsConversion(strategyBuilder);

    strategyBuilder.setSubmetric("custom");
    strategyBuilder.setStrategy((filterSet) => {
      const customDef = filterSet.getCustomDefinition("sales");
      if (!customDef || customDef.length === 0) {
        return null;
      }

      // Map submetrics to their consumption equivalents
      const consumptionMethods = {
        "domestic": (x) => self.getDomesticConsumption(x),
        "import": (x) => self.getImportConsumption(x),
        "export": (x) => self.getExportConsumption(x),
        "recycle": (x) => self.getRecycleConsumption(x),
      };

      const results = customDef.map((submetric) => {
        const method = consumptionMethods[submetric];
        return method ? method(filterSet) : null;
      }).filter((result) => result !== null);

      if (results.length === 0) {
        return null;
      }

      return results.reduce((a, b) => {
        if (!a) {
          return b;
        }
        if (!b) {
          return a;
        }
        if (a.getUnits() !== b.getUnits()) {
          throw new Error(
            `Cannot combine incompatible units: ${a.getUnits()} and ${b.getUnits()}`,
          );
        }
        const result = a.getValue() + b.getValue();
        return new EngineNumber(result, a.getUnits(), makeNumberUnambiguousString(result));
      });
    });
    self._addEmissionsConversion(strategyBuilder);
  }

  /**
   * Add thousand and million unit conversions to the builder.
   *
   * Creates strategies for converting population data to units, thousands of units,
   * and millions of units with validation.
   *
   * @private
   * @param {MetricStrategyBuilder} strategyBuilder - The strategy builder to
   *     configure.
   */
  _makeForThousandAndMillion(strategyBuilder) {
    const self = this;

    strategyBuilder.setUnits("units");
    strategyBuilder.setTransformation((value) => {
      if (value.getUnits() !== "units") {
        throw "Unexpected population units: " + value.getUnits();
      }
      return value;
    });
    strategyBuilder.add();

    strategyBuilder.setUnits("thousand units");
    strategyBuilder.setTransformation((value) => {
      if (value.getUnits() !== "units") {
        throw "Unexpected population units: " + value.getUnits();
      }
      const convertedValue = value.getValue() / 1000;
      return new EngineNumber(
        convertedValue, "thousands of units", makeNumberUnambiguousString(convertedValue),
      );
    });
    strategyBuilder.add();

    strategyBuilder.setUnits("million units");
    strategyBuilder.setTransformation((value) => {
      if (value.getUnits() !== "units") {
        throw "Unexpected population units: " + value.getUnits();
      }
      const convertedValue = value.getValue() / 1000000;
      return new EngineNumber(
        convertedValue, "millions of units", makeNumberUnambiguousString(convertedValue),
      );
    });
    strategyBuilder.add();
  }

  /**
   * Convert energy value to kwh/yr.
   *
   * Validates that the provided value has kwh units after time unit normalization
   * and returns a normalized EngineNumber in kwh/yr format.
   *
   * @private
   * @param {EngineNumber} value - The energy value to convert.
   * @returns {EngineNumber} The value in kwh/yr units.
   * @throws {string} If units are not kwh.
   */
  _getKwhYr(value) {
    const self = this;
    const normalizedUnits = self._normalizeTimeUnits(value.getUnits());
    if (normalizedUnits !== "kwh") {
      throw "Unexpected energy units: " + value.getUnits();
    }
    return new EngineNumber(
      value.getValue(), "kwh / yr", makeNumberUnambiguousString(value.getValue()),
    );
  }

  /**
   * Add energy unit conversions to the builder.
   *
   * Creates strategies for converting energy consumption data to kwh/yr, mwh/yr,
   * and gwh/yr units with validation.
   *
   * @private
   * @param {MetricStrategyBuilder} strategyBuilder - The strategy builder to
   *     configure.
   */
  _makeForEnergyUnits(strategyBuilder) {
    const self = this;
    strategyBuilder.setStrategy((x) => self.getEnergyConsumption(x));

    strategyBuilder.setUnits("kwh / year");
    strategyBuilder.setTransformation((value) => self._getKwhYr(value));
    strategyBuilder.add();

    strategyBuilder.setUnits("mwh / year");
    strategyBuilder.setTransformation((value) => {
      const kwhValue = self._getKwhYr(value);
      const convertedValue = kwhValue.getValue() / 1000;
      return new EngineNumber(
        convertedValue, "mwh / yr", makeNumberUnambiguousString(convertedValue),
      );
    });
    strategyBuilder.add();

    strategyBuilder.setUnits("gwh / year");
    strategyBuilder.setTransformation((value) => {
      const kwhValue = self._getKwhYr(value);
      const convertedValue = kwhValue.getValue() / 1000000;
      return new EngineNumber(
        convertedValue, "gwh / yr", makeNumberUnambiguousString(convertedValue),
      );
    });
    strategyBuilder.add();
  }

  /**
   * Add population strategies to the builder.
   *
   * Adds population and energy metrics including all population, new population,
   * and energy consumption with appropriate unit conversions.
   *
   * @private
   * @param {MetricStrategyBuilder} strategyBuilder - The strategy builder to
   *     configure.
   */
  _addPopulationStrategies(strategyBuilder) {
    const self = this;
    strategyBuilder.setMetric("population");

    strategyBuilder.setSubmetric("all");
    strategyBuilder.setStrategy((x) => self.getPopulation(x));
    self._makeForThousandAndMillion(strategyBuilder);
    self._makeForEnergyUnits(strategyBuilder);

    strategyBuilder.setSubmetric("new");
    strategyBuilder.setStrategy((x) => self.getPopulationNew(x));
    self._makeForThousandAndMillion(strategyBuilder);
    self._makeForEnergyUnits(strategyBuilder);
  }

  /**
   * Create a bank strategy that dispatches to kg or tCO2e methods.
   *
   * Returns a function that determines which method to use based on the requested
   * units in the filterSet, supporting both substance weight (kg/mt) and emissions
   * potential (tCO2e) representations.
   *
   * @private
   * @param {Function} kgMethod - Method to call for kg-based units.
   * @param {Function} tco2eMethod - Method to call for tCO2e-based units.
   * @returns {Function} Strategy function that dispatches based on units.
   * @throws {string} If units are unsupported.
   */
  _makeBankStrategy(kgMethod, tco2eMethod) {
    const self = this;
    return (filterSet) => {
      const units = filterSet.getUnits();
      const normalizedUnits = self._normalizeTimeUnits(units);

      // Determine which method to use based on requested units
      if (normalizedUnits === "kg" || normalizedUnits === "mt") {
        return kgMethod(filterSet);
      } else if (normalizedUnits === "tCO2e") {
        return tco2eMethod(filterSet);
      } else {
        throw "Unsupported bank units: " + units;
      }
    };
  }

  /**
   * Add bank strategies to the builder.
   *
   * Adds bank metrics including all bank and new bank values with support for both
   * substance weight (kg/mt) and emissions potential (tCO2e) units.
   *
   * @private
   * @param {MetricStrategyBuilder} strategyBuilder - The strategy builder to
   *     configure.
   */
  _addBankStrategies(strategyBuilder) {
    const self = this;
    strategyBuilder.setMetric("population");

    // kg -> mt conversion for substance bank
    strategyBuilder.setSubmetric("all");
    strategyBuilder.setStrategy(self._makeBankStrategy(
      (x) => self.getBankKg(x),
      (x) => self.getBankTco2e(x),
    ));
    strategyBuilder.setUnits("mt / yr");
    strategyBuilder.setTransformation((value) => {
      const normalizedUnits = self._normalizeTimeUnits(value.getUnits());
      if (normalizedUnits !== "kg") {
        throw "Unexpected bank units: " + value.getUnits();
      }
      const convertedValue = value.getValue() / 1000;
      return new EngineNumber(
        convertedValue, "mt / yr", makeNumberUnambiguousString(convertedValue),
      );
    });
    strategyBuilder.add();

    strategyBuilder.setSubmetric("all");
    strategyBuilder.setStrategy(self._makeBankStrategy(
      (x) => self.getBankKg(x),
      (x) => self.getBankTco2e(x),
    ));
    strategyBuilder.setUnits("kg / yr");
    strategyBuilder.setTransformation((value) => {
      const normalizedUnits = self._normalizeTimeUnits(value.getUnits());
      if (normalizedUnits !== "kg") {
        throw "Unexpected bank units: " + value.getUnits();
      }
      return new EngineNumber(
        value.getValue(), "kg / yr", makeNumberUnambiguousString(value.getValue()),
      );
    });
    strategyBuilder.add();

    strategyBuilder.setSubmetric("all");
    strategyBuilder.setStrategy(self._makeBankStrategy(
      (x) => self.getBankKg(x),
      (x) => self.getBankTco2e(x),
    ));
    strategyBuilder.setUnits("tCO2e / yr");
    strategyBuilder.setTransformation((value) => {
      const normalizedUnits = self._normalizeTimeUnits(value.getUnits());
      if (normalizedUnits !== "tCO2e") {
        throw "Unexpected bank units: " + value.getUnits();
      }
      return new EngineNumber(
        value.getValue(), "tCO2e / yr", makeNumberUnambiguousString(value.getValue()),
      );
    });
    strategyBuilder.add();

    // Same pattern for "new" submetric
    strategyBuilder.setSubmetric("new");
    strategyBuilder.setStrategy(self._makeBankStrategy(
      (x) => self.getBankChangeKg(x),
      (x) => self.getBankChangeTco2e(x),
    ));
    strategyBuilder.setUnits("mt / yr");
    strategyBuilder.setTransformation((value) => {
      const normalizedUnits = self._normalizeTimeUnits(value.getUnits());
      if (normalizedUnits !== "kg") {
        throw "Unexpected bank units: " + value.getUnits();
      }
      const convertedValue = value.getValue() / 1000;
      return new EngineNumber(
        convertedValue, "mt / yr", makeNumberUnambiguousString(convertedValue),
      );
    });
    strategyBuilder.add();

    strategyBuilder.setSubmetric("new");
    strategyBuilder.setStrategy(self._makeBankStrategy(
      (x) => self.getBankChangeKg(x),
      (x) => self.getBankChangeTco2e(x),
    ));
    strategyBuilder.setUnits("kg / yr");
    strategyBuilder.setTransformation((value) => {
      const normalizedUnits = self._normalizeTimeUnits(value.getUnits());
      if (normalizedUnits !== "kg") {
        throw "Unexpected bank units: " + value.getUnits();
      }
      return new EngineNumber(
        value.getValue(), "kg / yr", makeNumberUnambiguousString(value.getValue()),
      );
    });
    strategyBuilder.add();

    strategyBuilder.setSubmetric("new");
    strategyBuilder.setStrategy(self._makeBankStrategy(
      (x) => self.getBankChangeKg(x),
      (x) => self.getBankChangeTco2e(x),
    ));
    strategyBuilder.setUnits("tCO2e / yr");
    strategyBuilder.setTransformation((value) => {
      const normalizedUnits = self._normalizeTimeUnits(value.getUnits());
      if (normalizedUnits !== "tCO2e") {
        throw "Unexpected bank units: " + value.getUnits();
      }
      return new EngineNumber(
        value.getValue(), "tCO2e / yr", makeNumberUnambiguousString(value.getValue()),
      );
    });
    strategyBuilder.add();
  }

  /**
   * Get filtered set of values using a getter function.
   *
   * @private
   * @param {FilterSet} filterSet - The filter criteria.
   * @param {Function} getter - Function to get values from filtered results.
   * @returns {Set<*>} Set of filtered values.
   */
  _getSetAfterFilter(filterSet, getter) {
    const self = this;
    const afterFilter = self._applyFilterSet(filterSet);
    const values = afterFilter.map(getter);
    return new Set(values);
  }

  /**
   * Get the initial aggregated result from the first one or two items.
   *
   * Creates the starting aggregated result using either a single item or the
   * combination of the first two items, depending on array length.
   *
   * @private
   * @param {Array<*>} afterFilter - The filtered results array.
   * @returns {AggregatedResult} Initial aggregated result.
   */
  _getStartAggregated(afterFilter) {
    const self = this;
    if (afterFilter.length === 1) {
      return new AggregatedResult(afterFilter[0]);
    } else {
      return new AggregatedResult(afterFilter[0], afterFilter[1]);
    }
  }

  /**
   * Get aggregated result after applying filters.
   *
   * @private
   * @param {FilterSet} filterSet - The filter criteria.
   * @returns {AggregatedResult|null} Aggregated result or null if no matches.
   */
  _getAggregatedAfterFilter(filterSet) {
    const self = this;
    const afterFilter = self._applyFilterSet(filterSet);

    if (afterFilter.length === 0) {
      return null;
    }

    // Combine all results using the AggregatedResult constructor which takes two objects
    // and combines them. Start with the first two results and keep combining with the rest.
    let aggregated = self._getStartAggregated(afterFilter);

    // Continue combining with remaining results
    for (let i = 2; i < afterFilter.length; i++) {
      aggregated = new AggregatedResult(aggregated, afterFilter[i]);
    }

    return aggregated;
  }

  /**
   * Apply filter set to get matching results.
   *
   * @private
   * @param {FilterSet} filterSet - The filter criteria.
   * @returns {Array<*>} Array of matching results.
   */
  _applyFilterSet(filterSet) {
    const self = this;

    const step = (target, filterVal, getter) => {
      if (filterVal === null) {
        return target;
      }

      return target.filter((record) => {
        const candidateVal = getter(record);
        return candidateVal === filterVal;
      });
    };

    const stepWithSubtype = (target, filterVal, getter) => {
      if (filterVal === null) {
        return target;
      }

      const isMetaGroup = filterVal.endsWith(" - All");
      const withAllReplace = filterVal.replaceAll(" - All", " - ");

      return target.filter((record) => {
        const candidateVal = getter(record);

        if (isMetaGroup) {
          return candidateVal.startsWith(withAllReplace);
        } else {
          return candidateVal === filterVal;
        }
      });
    };

    const allRecords = self.getRawData(filterSet);

    const scenario = filterSet.getScenario();
    const withScenario = step(allRecords, scenario, (x) => x.getScenarioName());

    const year = filterSet.getYear();
    const withYear = step(withScenario, year, (x) => x.getYear());

    const app = filterSet.getApplication();
    const withApp = stepWithSubtype(withYear, app, (x) => x.getApplication());

    const sub = filterSet.getSubstance();
    const withSub = stepWithSubtype(withApp, sub, (x) => x.getSubstance());

    return withSub;
  }

  /**
   * Decorate the inner data (EngineResult) to attribute to exporter.
   *
   * @private
   * @param {Array<EngineResult>} rawResults - The results with attribute
   *     to importer that should be decorated.
   * @returns {Array<AttributeToExporterResult>} Decorated version.
   */
  _buildExporterAttributed(rawResults) {
    const self = this;
    return rawResults.map((x) => new AttributeToExporterResult(x));
  }

  /**
   * Check if an error is related to metricStrategy function issues.
   *
   * @private
   * @param {Error} error - The error to check.
   * @returns {boolean} True if this is a metricStrategy error that should trigger reset.
   */
  _isMetricStrategyError(error) {
    const self = this;

    if (!error.message) {
      return false;
    }

    if (!error.message.includes("not a function")) {
      return false;
    }

    if (!error.message.toLowerCase().includes("metricstrategy")) {
      return false;
    }

    return true;
  }
}

export {ReportDataWrapper, MetricStrategyBuilder};