UNPKG

xpm

Version:

The xPack project manager command line tool

2,128 lines 76.2 kB
/*
 * This file is part of the xPack project (http://xpack.github.io).
 * Copyright (c) 2021-2026 Liviu Ionescu. All rights reserved.
 *
 * Permission to use, copy, modify, and/or distribute this software
 * for any purpose is hereby granted, under the terms of the MIT license.
 *
 * If a copy of the license was not distributed with this file, it can
 * be obtained from https://opensource.org/license/mit.
 */

// ----------------------------------------------------------------------------

import assert from 'node:assert'
import * as path from 'node:path'
import * as os from 'node:os'

import { Logger } from '@xpack/logger'

// ----------------------------------------------------------------------------

import { LiquidEngine } from './liquid-engine.js'
import {
  LiquidSubstitutionsVariables,
  LiquidSubstitutionsStrings,
} from '../data/substitutions-variables.js'
import { filterPath } from '../functions/filter-paths.js'
import { isJsonObject, isString } from '../functions/is-something.js'
import { getErrorMessage, hasLiquidSyntax } from '../functions/utils.js'
import { performSubstitutions } from '../functions/perform-substitutions.js'
import {
  JsonBuildConfigurations,
  JsonBuildConfigurationTemplate,
  JsonBuildConfiguration,
  JsonBuildConfigurationContent,
  JsonDependencies,
  JsonBuildConfigurationInherits,
} from '../types/json.js'
import { Actions, Action } from './actions.js'
import { buildFolderRelativePathPropertyName } from './data-model.js'
import { ConfigurationError } from './errors.js'
import { TemplateExpander } from './template-expander.js'

// ============================================================================

/**
 * Configuration parameters for constructing a build configurations collection.
 *
 * @remarks
 * This interface defines the required configuration for creating an
 * instance of {@link BuildConfigurations}. Most properties are mandatory
 * except for the optional <code>jsonBuildConfigurations</code>, which can
 * be undefined if there are no build configurations defined in the package.
 *
 * The parameters provide the collection with access to the Liquid templating
 * engine, substitution variables hierarchy, build configuration definitions
 * from the package manifest, and the logger for diagnostic output during
 * configuration processing.
 */
export interface BuildConfigurationsConstructorParameters {
  /**
   * The Liquid templating engine for variable substitution.
   */
  engine: LiquidEngine

  /**
   * The variables available for substitution in configuration definitions.
   */
  substitutionsVariables: LiquidSubstitutionsVariables

  /**
   * The JSON build configurations definitions, or undefined if no build
   * configurations are defined.
   */
  jsonBuildConfigurations: JsonBuildConfigurations | undefined

  /**
   * The logger instance for output and diagnostics.
   */
  log: Logger
}

/**
 * A collection of <b>xpm</b> build configurations.
 *
 * @remarks
 * This class manages build configurations defined in package metadata,
 * including template expansion with matrix parameters and initialisation of
 * derived configuration instances.
 *
 * Configuration lifecycle phases:
 *
 * <ol>
 * <li><b>Construction:</b> Basic setup without processing configurations.</li>
 * <li><b>Initialisation:</b> Template name expansion without content
 *    evaluation.</li>
 * <li><b>Retrieval:</b> On-demand instantiation when accessed via
 *    <code>get()</code>.</li>
 * <li><b>Configuration Initialisation:</b> Full processing including
 *    inheritance, property resolution, dependency substitution, and
 *    action preparation.</li>
 * </ol>
 *
 * This lazy evaluation strategy ensures that only configurations actually
 * used incur the cost of template evaluation, inheritance resolution, and
 * variable substitution.
 */
export class BuildConfigurations {
  // --------------------------------------------------------------------------
  // Public Members.

  /**
   * The logger instance for output and diagnostics.
   *
   * @remarks
   * This logger provides trace-level diagnostics throughout the build
   * configuration lifecycle, including template expansion, inheritance
   * resolution, property merging, and dependency substitution. It enables
   * detailed debugging of complex build configuration hierarchies without
   * impacting runtime performance when tracing is disabled.
   */
  readonly log: Logger

  /**
   * The Liquid templating engine for variable substitution.
   *
   * @remarks
   * This engine instance is shared across all build configurations and
   * configured with custom filters for platform detection, path
   * manipulation, and xpm-specific operations. It processes templates in
   * configuration names, matrix parameters, properties, dependencies, and
   * actions, ensuring consistent template evaluation throughout the
   * configuration lifecycle.
   */
  readonly engine: LiquidEngine

  /**
   * The variables available for substitution in configuration definitions.
   *
   * @remarks
   * This comprehensive variable hierarchy provides the base context for all
   * build configuration template evaluation, extended per-configuration with
   * specific properties, dependencies, and matrix parameters.
   *
   * Base hierarchy includes:
   *
   * <ol>
   * <li><b>Environment variables:</b> <code>env</code> namespace with system
   *    environment.</li>
   * <li><b>Platform detection:</b> <code>os</code> namespace with
   *    platform-specific values.</li>
   * <li><b>Path utilities:</b> <code>path</code> namespace with path
   *    manipulationfunctions.</li>
   * <li><b>Package metadata:</b> <code>package</code> namespace with
   *    name, version, dependencies.</li>
   * </ol>
   *
   * Individual configurations extend this with their own `properties`,
   * `configuration`, and `matrix` namespaces during initialisation.
   */
  readonly substitutionsVariables: LiquidSubstitutionsVariables

  /**
   * The JSON object containing build configuration definitions.
   *
   * @remarks
   * This object holds raw build configuration definitions from the
   * `package.json` `xpack.buildConfigurations` section. Configurations can be:
   *
   * <ol>
   * <li><b>Regular configurations:</b> Direct objects with properties,
   *    dependencies, actions, and inheritance.</li>
   * <li><b>Template configurations:</b> Objects with <code>matrix</code>
   *    and <code>template</code>
   *    properties for generating multiple configurations from a single
   *    definition.</li>
   * </ol>
   *
   * Template configuration names (containing `{{` markers) trigger matrix
   * expansion during initialisation, creating concrete configurations from
   * the Cartesian product of matrix parameter values. Each configuration
   * can inherit from others, creating complex dependency hierarchies.
   */
  readonly jsonBuildConfigurations: JsonBuildConfigurations

  // --------------------------------------------------------------------------
  // Protected Members.

  /**
   * Map of build configuration names to their corresponding instances.
   *
   * @remarks
   * This map serves as the primary configuration registry, populated during
   * collection initialisation with entries for all discovered configurations.
   *
   * Key characteristics:
   *
   * <ol>
   * <li>Known only after <code>BuildConfigurations.initialise()</code>
   *    completes.</li>
   * <li>Possibly empty if there are no build configurations defined.</li>
   * <li>Values can be <code>undefined</code> to indicate a configuration
   *    exists but hasn't been instantiated yet (lazy loading).</li>
   * <li>For template configurations, contains one entry per expanded
   *    combination, not the original template definition.</li>
   * </ol>
   *
   * Configurations transition from `undefined` to instantiated when first
   * accessed via {@link BuildConfigurations.get}, implementing the
   * lazy evaluation pattern to avoid unnecessary processing.
   */
  protected readonly _buildConfigurationsMap: Map<
    string,
    BuildConfiguration | undefined
  > = new Map<string, BuildConfiguration | undefined>()

  /**
   * Map of expanded build configuration names to their JSON source names.
   *
   * @remarks
   * This reverse mapping enables retrieving the original configuration
   * definition from `jsonBuildConfigurations` when lazy-loading
   * configuration instances.
   *
   * Mapping behavior:
   *
   * <ol>
   * <li><b>For regular configurations:</b> Maps configuration name to itself
   *    (identity mapping).</li>
   * <li><b>For template configurations:</b> Maps each generated configuration
   *    name
   *    back to the original template name (e.g., <code>release-x64</code> →
   *    <code>release-\{\{ matrix.arch \}\}</code>).</li>
   * <li>Known only after <code>BuildConfigurations.initialise()</code>
   *    completes.</li>
   * <li>Enables <code>BuildConfigurations.get()</code> to locate the
   *    correct JSON definition when instantiating a configuration on
   *    demand.</li>
   * </ol>
   *
   * This indirection is essential for lazy evaluation, allowing deferred
   * instantiation while maintaining the connection to original definitions.
   */
  protected readonly _jsonBuildConfigurationsNamesMap: Map<string, string> =
    new Map<string, string>()

  /**
   * Set of all build configuration names for duplicate detection.
   *
   * @remarks
   * This set provides O(1) existence checks for configuration names,
   * enabling efficient validation during template expansion to prevent
   * duplicate configurations.
   *
   * Duplicate scenarios detected:
   *
   * <ol>
   * <li>Explicit duplicates in <code>package.json</code> with identical
   *    names.</li>
   * <li>Template expansion conflicts where different templates generate the
   *    same concrete configuration name.</li>
   * <li>Conflicts between template-generated names and explicitly defined
   *    configuration names.</li>
   * </ol>
   *
   * Detection occurs during {@link BuildConfigurations.initialise},
   * throwing {@link ConfigurationError} when duplicates are found to ensure
   * configuration name uniqueness.
   */
  protected readonly _namesSet: Set<string> = new Set<string>()

  /**
   * Flag indicating whether the collection has been initialised.
   *
   * @remarks
   * This flag prevents redundant initialisation and ensures idempotent
   * behavior when {@link BuildConfigurations.initialise} is called
   * multiple times.
   *
   * State transitions:
   *
   * <ol>
   * <li>Initially <code>false</code> after construction.</li>
   * <li>Set to <code>true</code> after successful template expansion
   *    and configuration
   *    name registration.</li>
   * <li>Checked at the beginning of
   *    <code>BuildConfigurations.initialise()</code> to return early if
   *    already initialised.</li>
   * </ol>
   *
   * This pattern supports safe repeated calls during complex initialisation
   * sequences without duplicating work or corrupting internal state.
   */
  protected _isInitialised = false

  /**
   * Cached array of all build configuration names in the collection.
   *
   * @remarks
   * This array provides O(1) access to configuration names without
   * repeatedly creating new arrays from the map keys, improving performance
   * when the names are accessed multiple times.
   *
   * Key characteristics:
   *
   * <ol>
   * <li>Empty initially after construction.</li>
   * <li>Populated during
   *    <code>BuildConfigurations.initialise()</code> after all
   *    configuration names are determined.</li>
   * <li>Contains all configuration names including those generated from
   *    templates.</li>
   * <li>Returned by the <code>names</code> getter for efficient repeated
   *    access.</li>
   * </ol>
   *
   * This cached approach avoids the overhead of calling
   * `Array.from(map.keys())` on every access whilst still
   * providing a clean getter interface.
   */
  protected _names: string[] = []

  // --------------------------------------------------------------------------
  // Constructor and async initialiser.

  /**
   * Constructs a build configurations collection.
   *
   * @remarks
   * The constructor performs partial initialisation. Complete
   * initialisation requires calling
   * {@link BuildConfigurations.initialise}.
   *
   * @param engine - The Liquid templating engine for variable substitution.
   * @param substitutionsVariables - The variables available for substitution.
   * @param jsonBuildConfigurations - The JSON build configurations definitions,
   * or undefined if no build configurations are defined.
   * @param log - The logger instance for output and diagnostics.
   */
  constructor({
    engine,
    substitutionsVariables,
    jsonBuildConfigurations,
    log,
  }: BuildConfigurationsConstructorParameters) {
    assert(log, 'log is required')
    assert(engine, 'engine is required')
    assert(substitutionsVariables, 'substitutionsVariables is required')

    log.trace(`${BuildConfigurations.name}()`)

    this.log = log
    this.engine = engine
    this.substitutionsVariables = substitutionsVariables
    this.jsonBuildConfigurations = jsonBuildConfigurations ?? {}

    // log.trace('substitutionsVariables => ', this.substitutionsVariables)
  }

  /**
   * Completes the async initialisation of the build configurations collection.
   *
   * @remarks
   * This method implements the first step of lazy evaluation. It processes
   * all build configuration definitions by expanding template configuration
   * names based on matrix parameters, but does not evaluate the configuration
   * content or perform Liquid substitutions. The actual template evaluation
   * and variable substitution occur later when individual configurations are
   * initialised via {@link BuildConfiguration.initialise}, and only
   * for configurations that are actually used. This approach avoids unnecessary
   * operations on unused configurations.
   *
   * Processing steps:
   *
   * <ol>
   * <li>Return early if already initialised (idempotent behaviour).</li>
   * <li>Iterate through all build configuration definitions from the JSON
   *    object.</li>
   * <li>For template configurations (names containing <code>\{\{</code>):
   *   <ul>
   *   <li>Call <code>_processTemplate()</code> to expand and register all
   *      generated configurations.</li>
   *   </ul>
   * </li>
   * <li>For regular configurations:
   *   <ul>
   *   <li>Validate uniqueness of the configuration name.</li>
   *   <li>Register the configuration in internal maps with
   *      <code>undefined</code> value (lazy loading).</li>
   *   </ul>
   * </li>
   * <li>Cache the array of all configuration names for efficient repeated
   *    access.</li>
   * </ol>
   *
   * @returns A promise that resolves to `true` if initialisation was performed,
   * or `false` if already initialised.
   *
   * @throws {@link ConfigurationError}
   * If duplicate names are detected or template expansion fails.
   */
  async initialise(): Promise<boolean> {
    const log = this.log

    if (this._isInitialised) {
      log.trace(`${BuildConfigurations.name}.initialise() again`)
      return false
    }

    log.trace(`${BuildConfigurations.name}.initialise()`)

    for (const [
      buildConfigurationName,
      jsonBuildConfiguration,
    ] of Object.entries(this.jsonBuildConfigurations)) {
      if (hasLiquidSyntax(buildConfigurationName)) {
        await this._processTemplate({
          buildConfigurationName,
          jsonBuildConfigurationTemplate:
            jsonBuildConfiguration as JsonBuildConfigurationTemplate,
        })
      } else {
        if (this._namesSet.has(buildConfigurationName)) {
          throw new ConfigurationError(
            `build configuration name ` +
              `"${buildConfigurationName}" already defined`
          )
        } else {
          this._buildConfigurationsMap.set(buildConfigurationName, undefined)
          this._jsonBuildConfigurationsNamesMap.set(
            buildConfigurationName,
            buildConfigurationName
          )
          this._namesSet.add(buildConfigurationName)
        }
      }
    }

    const names = Array.from(this._buildConfigurationsMap.keys())
    this._names = names

    log.trace(`${BuildConfigurations.name}.initialise() =>`, names)

    this._isInitialised = true
    return true
  }

  // --------------------------------------------------------------------------
  // Public Methods.

  /**
   * The number of build configurations in the collection.
   *
   * @remarks
   * This value is known only after `initialise()`.
   *
   * This getter provides direct access to the collection size, enabling
   * callers to check for emptiness or iterate with knowledge of the
   * collection's extent.
   *
   * @returns The number of build configurations in the collection.
   */
  get size(): number {
    assert(
      this._isInitialised,
      'BuildConfigurations collection must be initialised before ' +
        'accessing size'
    )

    return this._buildConfigurationsMap.size
  }

  /**
   * Indicates whether the collection is empty.
   *
   * @remarks
   * This value is known only after `initialise()`.
   *
   * @returns `true` if there are no build configurations, `false` otherwise.
   */
  get isEmpty(): boolean {
    assert(
      this._isInitialised,
      'BuildConfigurations collection must be initialised before ' +
        'accessing isEmpty'
    )

    return this._buildConfigurationsMap.size === 0
  }

  /**
   * The names of all build configurations.
   *
   * @remarks
   * This value is known only after `initialise()`.
   *
   * This getter returns the cached array of configuration names for
   * efficient repeated access without recreating the array.
   *
   * @returns An array of build configuration names.
   */
  get names(): string[] {
    assert(
      this._isInitialised,
      'BuildConfigurations collection must be initialised before ' +
        'accessing names'
    )

    return this._names
  }

  /**
   * Retrieves the JSON configuration name for a build configuration.
   *
   * @param buildConfigurationName - The build configuration name to resolve.
   * @returns The JSON configuration name associated with the given build
   * configuration name.
   *
   * @remarks
   * For template-generated configurations, this returns the template
   * name.
   *
   * @throws {@link InputError}
   * If the build configuration does not exist.
   */
  getJsonName(buildConfigurationName: string): string {
    assert(
      this._isInitialised,
      'BuildConfigurations collection must be initialised before ' +
        'accessing getJsonName()'
    )

    const name = this._jsonBuildConfigurationsNamesMap.get(
      buildConfigurationName
    )
    if (name === undefined) {
      throw new ConfigurationError(
        `build configuration "${buildConfigurationName}" does not exist`
      )
    }

    return name
  }

  /**
   * Determines whether a JSON definition exists for a build configuration.
   *
   * @param buildConfigurationName - The build configuration name to check.
   * @returns `true` if a JSON definition exists, `false` otherwise.
   */
  hasJson(buildConfigurationName: string): boolean {
    assert(
      this._isInitialised,
      'BuildConfigurations collection must be initialised before ' +
        'accessing hasJson()'
    )

    return this._jsonBuildConfigurationsNamesMap.has(buildConfigurationName)
  }

  /**
   * Retrieves the JSON build configuration definition.
   *
   * @param buildConfigurationName - The build configuration name to resolve.
   * @returns The JSON build configuration definition.
   */
  getJson(buildConfigurationName: string): JsonBuildConfiguration {
    assert(
      this._isInitialised,
      'BuildConfigurations collection must be initialised before ' +
        'accessing getJson()'
    )

    return this.jsonBuildConfigurations[
      this.getJsonName(buildConfigurationName)
    ]
  }

  /**
   * Determines whether a build configuration is hidden.
   *
   * @param buildConfigurationName - The build configuration name to check.
   * @returns `true` if the configuration is hidden, `false` otherwise.
   */
  isHidden(buildConfigurationName: string): boolean {
    assert(
      this._isInitialised,
      'BuildConfigurations collection must be initialised before ' +
        'accessing isHidden()'
    )

    const jsonBuildConfigurationName = this.getJsonName(buildConfigurationName)
    if (jsonBuildConfigurationName.includes('{{')) {
      const jsonBuildConfigurationTemplate: JsonBuildConfigurationTemplate =
        this.jsonBuildConfigurations[
          jsonBuildConfigurationName
        ] as JsonBuildConfigurationTemplate
      return jsonBuildConfigurationTemplate.template.hidden ?? false
    }

    const jsonBuildConfigurationContent: JsonBuildConfigurationContent = this
      .jsonBuildConfigurations[
      jsonBuildConfigurationName
    ] as JsonBuildConfigurationContent
    return jsonBuildConfigurationContent.hidden ?? false
  }

  /**
   * Determines whether a build configuration exists in the collection.
   *
   * @param buildConfigurationName - The build configuration name to check.
   * @returns `true` if the configuration exists, `false` otherwise.
   */
  has(buildConfigurationName: string): boolean {
    assert(
      this._isInitialised,
      'BuildConfigurations collection must be initialised before ' +
        'accessing has()'
    )

    return this._buildConfigurationsMap.has(buildConfigurationName)
  }

  /**
   * Retrieves a build configuration by name, creating it if required.
   *
   * @remarks
   * This method implements lazy evaluation to avoid unnecessary
   * operations. Build configurations are instantiated on demand but
   * remain uninitialised until actually used.
   *
   * Retrieval process:
   *
   * <ol>
   * <li>Check if the configuration already exists in the internal map.</li>
   * <li>If found and already instantiated, return the existing instance.</li>
   * <li>If the configuration name is unknown (not in JSON name mapping),
   *    throw <code>InputError</code>.</li>
   * <li>For known but not yet instantiated configurations:
   *   <ul>
   *   <li>Resolve the original JSON configuration name (handles both
   *      regular and template-generated configurations).</li>
   *   <li>Retrieve the JSON configuration definition.</li>
   *   <li>Create a new <code>BuildConfiguration</code> instance.</li>
   *   <li>Store the instance in the map for future access.</li>
   *   </ul>
   * </li>
   * <li>Return the configuration instance (still uninitialised).</li>
   * </ol>
   *
   * The two-step lazy evaluation process:
   *
   * <ol>
   * <li>During collection initialisation
   *    (<code>BuildConfigurations.initialise()</code>), only the
   *    matrix of options is evaluated for each template, expanding
   *    configuration names without processing their content.</li>
   * <li>Later, when a configuration is accessed via this method and
   *    subsequently initialised
   *    (<code>BuildConfiguration.initialise()</code>), the template
   *    is fully evaluated and Liquid substitutions are performed on
   *    all properties.</li>
   * </ol>
   *
   * This approach ensures that only build configurations that are
   * actually used incur the cost of template evaluation and variable
   * substitution.
   *
   * @param buildConfigurationName - The build configuration name to retrieve.
   * @returns The build configuration instance.
   *
   * @throws {@link InputError}
   * If a configuration with the specified name does not exist.
   */
  get(buildConfigurationName: string): BuildConfiguration {
    assert(
      this._isInitialised,
      'BuildConfigurations collection must be initialised before ' +
        'accessing get()'
    )

    const log = this.log
    log.trace(`${BuildConfigurations.name}.get(${buildConfigurationName})`)

    let buildConfiguration = this._buildConfigurationsMap.get(
      buildConfigurationName
    )
    if (buildConfiguration === undefined) {
      // This will throw InputError if the configuration doesn't exist
      const jsonBuildConfigurationName: string = this.getJsonName(
        buildConfigurationName
      )

      // Safety net: This fallback to empty object is defensive programming.
      // The jsonBuildConfigurations[jsonBuildConfigurationName] should always
      // be defined because getJsonName() throws if the configuration doesn't
      // exist. The ?? {} provides protection against unexpected inconsistencies
      // between the names map and the configurations object.
      /* c8 ignore start - safety net, they are always defined */
      const jsonBuildConfiguration: JsonBuildConfigurationContent = (this
        .jsonBuildConfigurations[jsonBuildConfigurationName] ??
        {}) as JsonBuildConfigurationContent
      /* c8 ignore stop */

      buildConfiguration = new BuildConfiguration({
        buildConfigurationName,
        jsonBuildConfiguration,
        parentBuildConfigurations: this,
      })
      this._buildConfigurationsMap.set(
        buildConfigurationName,
        buildConfiguration
      )
    }

    // await buildConfiguration.initialise()
    return buildConfiguration
  }

  // --------------------------------------------------------------------------
  // Private Methods.

  /**
   * Processes a template build configuration by expanding it and registering
   * the generated configurations.
   *
   * @remarks
   * This helper method is called during collection initialisation for each
   * build configuration whose name contains template syntax
   * (<code>\{\{</code> markers).
   *
   * Processing steps:
   *
   * <ol>
   * <li>Calls <code>_expandTemplateBuildConfigurations()</code> to generate
   *    all configuration instances from the template's matrix parameters.</li>
   * <li>Validates that each expanded configuration name is unique and does
   *    not conflict with existing configurations.</li>
   * <li>Registers each expanded configuration in the internal maps:
   *   <ul>
   *   <li><code>_buildConfigurationsMap</code>: Maps name to configuration
   *      instance.</li>
   *   <li><code>_jsonBuildConfigurationsNamesMap</code>: Maps expanded name
   *      back to original template name.</li>
   *   <li><code>_namesSet</code>: Tracks all registered
   *      names for duplicate detection.</li>
   *   </ul>
   * </li>
   * </ol>
   *
   * @param buildConfigurationName - The template configuration name
   * containing Liquid variables.
   * @param jsonBuildConfiguration - The JSON template definition containing
   * matrix parameters and a configuration template.
   * @returns A promise that resolves when processing is complete.
   *
   * @throws {@link ConfigurationError}
   * If duplicate configuration names are detected during expansion or if
   * template expansion fails.
   */
  protected async _processTemplate({
    buildConfigurationName,
    jsonBuildConfigurationTemplate,
  }: {
    buildConfigurationName: string
    jsonBuildConfigurationTemplate: JsonBuildConfigurationTemplate
  }): Promise<void> {
    // Expand templates and generate multiple build configurations.
    try {
      const expandedBuildConfigurationsMap =
        await this._expandTemplateBuildConfigurations({
          buildConfigurationName,
          jsonBuildConfigurationTemplate,
        })
      for (const [
        expandedBuildConfigurationName,
        expandedBuildConfiguration,
      ] of expandedBuildConfigurationsMap) {
        if (this._namesSet.has(expandedBuildConfigurationName)) {
          throw new ConfigurationError(
            `duplicate build configuration name ` +
              `"${expandedBuildConfigurationName}" ` +
              `could not be generated from template.`
          )
        } else {
          this._buildConfigurationsMap.set(
            expandedBuildConfigurationName,
            expandedBuildConfiguration
          )
          this._jsonBuildConfigurationsNamesMap.set(
            expandedBuildConfigurationName,
            buildConfigurationName
          )
          this._namesSet.add(expandedBuildConfigurationName)
        }
      }
    } catch (error) {
      const message =
        getErrorMessage(error) +
        ` in buildConfiguration "${buildConfigurationName}"`
      throw new ConfigurationError(message)
    }
  }

  /**
   * Expands a template build configuration into multiple configurations.
   *
   * @remarks
   * This method uses the {@link TemplateExpander} to compute the Cartesian
   * product of matrix parameter values and creates a configuration for each
   * combination, substituting matrix values into both the configuration name
   * and content.
   *
   * Processing steps:
   *
   * <ol>
   * <li>Validates matrix and template structure.</li>
   * <li>Delegates to <code>TemplateExpander</code> for matrix processing and
   *    name expansion.</li>
   * <li>Creates configuration instances via factory callback for each
   *    combination.</li>
   * </ol>
   *
   * Matrix variables are scoped to individual configurations and accessible
   * via the `matrix` namespace during property, dependency, and action
   * evaluation.
   *
   * @param buildConfigurationName - The template configuration name containing
   * Liquid variables.
   * @param jsonBuildConfigurationTemplate - The template definition containing
   * matrix parameters and a configuration template.
   * @returns A promise that resolves to a map of expanded configuration names
   * to their corresponding instances.
   *
   * @throws {@link ConfigurationError}
   * If the matrix structure is invalid or substitution fails.
   */
  protected async _expandTemplateBuildConfigurations({
    buildConfigurationName,
    jsonBuildConfigurationTemplate,
  }: {
    buildConfigurationName: string
    jsonBuildConfigurationTemplate: JsonBuildConfigurationTemplate
  }): Promise<Map<string, BuildConfiguration>> {
    const log = this.log
    log.trace(
      `${BuildConfigurations.name}.` +
        `#expandTemplateBuildConfigurations(${buildConfigurationName})`
    )

    // Validate template structure
    if (!isJsonObject(jsonBuildConfigurationTemplate.matrix)) {
      throw new ConfigurationError(
        `buildConfiguration "${buildConfigurationName}" ` +
          `matrix is not an object`
      )
    }
    if (!isJsonObject(jsonBuildConfigurationTemplate.template)) {
      throw new ConfigurationError(
        `buildConfiguration "${buildConfigurationName}" ` +
          `template is not a JSON object`
      )
    }

    // Use TemplateExpander for matrix processing and expansion
    const expander = new TemplateExpander<
      JsonBuildConfigurationContent,
      BuildConfiguration
    >({
      engine: this.engine,
      substitutionsVariables: this.substitutionsVariables,
      log: this.log,
    })

    return await expander.expandTemplate({
      templateName: buildConfigurationName,
      matrix: jsonBuildConfigurationTemplate.matrix,
      templateContent: jsonBuildConfigurationTemplate.template,
      templateType: 'buildConfiguration',
      instanceFactory: (
        expandedName: string,
        combination: Record<string, string>,
        templateContent: JsonBuildConfigurationContent,
        originalTemplateName: string
      ) =>
        new BuildConfiguration({
          buildConfigurationName: expandedName,
          templateBuildConfigurationName: originalTemplateName,
          jsonBuildConfiguration: templateContent,
          parentBuildConfigurations: this,
          matrixParameters: { ...combination },
        }),
    })
  }
}

// ============================================================================

/**
 * Configuration parameters for constructing a build configuration instance.
 *
 * @remarks
 * This interface defines the required configuration for creating an
 * instance of {@link BuildConfiguration}. Most properties are mandatory
 * except for the optional <code>templateBuildConfigurationName</code> and
 * <code>matrixParameters</code>, which are only needed for template-generated
 * configurations created from matrix expansion.
 *
 * The parameters provide the configuration with its identity (name,
 * optional template name), the JSON configuration definition, access to
 * the parent collection for shared resources, and optional matrix parameter
 * values for template-generated configurations.
 */
export interface BuildConfigurationConstructorParameters {
  /**
   * The configuration name after substitution.
   */
  buildConfigurationName: string

  /**
   * The template configuration name, if derived from a template.
   */
  templateBuildConfigurationName?: string

  /**
   * The JSON configuration definition.
   */
  jsonBuildConfiguration: JsonBuildConfigurationContent

  /**
   * The parent configurations collection.
   */
  parentBuildConfigurations: BuildConfigurations

  /**
   * Optional matrix parameter values for template-generated configurations.
   */
  matrixParameters?: LiquidSubstitutionsStrings
}

/**
 * An individual <b>xpm</b> build configuration.
 *
 * @remarks
 * Build configurations are initialised lazily and may inherit
 * properties, dependencies, and actions from other configurations.
 *
 * A configuration can exist in three states:
 *
 * <ol>
 * <li><b>Undefined:</b> Name is known but instance not yet created.</li>
 * <li><b>Instantiated:</b> Object exists but not yet fully processed.</li>
 * <li><b>Initialised:</b> Inheritance resolved, properties evaluated,
 *    dependencies substituted, and actions prepared.</li>
 * </ol>
 *
 * Inheritance is processed recursively with circular reference detection.
 * Later inherited properties override earlier ones, and local properties
 * override all inherited ones. Dependencies and actions are merged from
 * all inherited configurations.
 */
export class BuildConfiguration {
  // --------------------------------------------------------------------------
  // Public Members.

  /**
   * The build configuration name after substitution.
   *
   * @remarks
   * This is the final, expanded configuration name used for identification
   * and selection. For template-generated configurations, this is the
   * concrete name after matrix substitution (e.g., `release-x64` rather than
   * `release-{{ matrix.arch }}`).
   *
   * The name is used for:
   *
   * <ol>
   * <li>User-facing identification when listing or selecting
   *   configurations.</li>
   * <li><b>Build folder path generation (default:</b>
   *   <code>build/\{name\}</code>).</li>
   * <li>Logging and diagnostic output to track configuration lifecycle.</li>
   * <li>Inheritance references from other configurations.</li>
   * </ol>
   *
   * Names must be unique within the configurations collection, enforced
   * during {@link BuildConfigurations.initialise}.
   */
  readonly name: string

  /**
   * The template build configuration name, if derived from a template.
   *
   * @remarks
   * For template-generated configurations, this preserves the original
   * template name containing Liquid variables (e.g.,
   * `release-{{ matrix.arch }}`), while `buildConfigurationName` holds the
   * expanded concrete name.
   *
   * Usage:
   *
   * <ol>
   * <li>Undefined for regular (non-template) configurations.</li>
   * <li>Set to the template name for configurations generated from matrix
   *    expansion.</li>
   * <li>Used to determine whether full JSON substitution is needed during
   *    initialisation (templates require complete substitution, regular
   *    configurations only substitute specific fields).</li>
   * <li>Enables tracing and debugging of template expansion process.</li>
   * </ol>
   */
  readonly templateName?: string

  /**
   * The parent build configurations collection.
   *
   * @remarks
   * This reference maintains the hierarchical relationship between
   * individual configurations and their containing collection, providing
   * essential context for configuration initialisation.
   *
   * The parent collection provides access to:
   *
   * <ol>
   * <li>Liquid templating engine for variable substitution.</li>
   * <li>Base substitution variables hierarchy (package metadata,
   *    environment, platform detection).</li>
   * <li>Logger instance for diagnostic output.</li>
   * <li>JSON build configurations lookup for inheritance resolution.</li>
   * <li>Other configuration instances when processing inheritance chains.</li>
   * </ol>
   *
   * This design enables configurations to access shared resources without
   * duplicating them, while supporting complex inheritance relationships
   * where configurations reference and inherit from each other.
   */
  readonly parentBuildConfigurations: BuildConfigurations

  /**
   * The list of inherited configuration names.
   *
   * @remarks
   * This array specifies the inheritance chain for this configuration,
   * processed sequentially during initialisation with later entries
   * overriding earlier ones.
   *
   * Inheritance processing:
   *
   * <ol>
   * <li>Populated from <code>inherits</code> or deprecated
   *    <code>inherit</code> field during
   *    initialisation.</li>
   * <li>Supports both string (single parent) and array (multiple parents)
   *    formats.</li>
   * <li>Each inherited configuration is initialised recursively before
   *    merging its properties, dependencies, and actions.</li>
   * <li>Circular references are detected and rejected with
   *    <code>InputError</code>.</li>
   * <li>Later inherited configurations override properties from earlier
   *    ones, and local properties override all inherited ones.</li>
   * </ol>
   */
  inheritsNames: string[] = []

  /**
   * Indicates whether the configuration is hidden.
   *
   * @remarks
   * Hidden configurations are used for inheritance bases or intermediate
   * configurations that shouldn't be directly selected for building.
   *
   * Effects of hidden status:
   *
   * <ol>
   * <li>Hidden configurations don't compute build folder relative paths
   *    during initialisation (optimization for inheritance-only configs).</li>
   * <li>May be excluded from user-facing configuration lists depending on
   *    application logic.</li>
   * <li>Still fully initialised and available for inheritance by other
   *    configurations.</li>
   * <li>Derived from <code>hidden</code> field in JSON configuration definition
   *    (defaults to <code>false</code>).</li>
   * </ol>
   *
   * Common use case:
   *
   * Base configurations that define common properties,
   * dependencies, or actions inherited by multiple concrete configurations.
   */
  readonly isHidden: boolean

  /**
   * The resolved properties for this configuration.
   *
   * @remarks
   * This object contains the final merged properties after inheritance
   * resolution and becomes available in the `properties` namespace for
   * Liquid template substitution.
   *
   * Property resolution order:
   *
   * <ol>
   * <li>Start with empty object.</li>
   * <li>Merge properties from each inherited configuration in sequence
   *    (later overrides earlier).</li>
   * <li>Merge local properties from JSON definition (overrides all
   *    inherited).</li>
   * <li>Add computed <code>buildFolderRelativePath</code> property
   *    for non-hidden
   *    configurations.</li>
   * </ol>
   *
   * Properties are accessible in templates as `{{ properties.key }}` and
   * commonly used for compiler flags, toolchain paths, optimization
   * settings, and build-specific configuration values.
   */
  properties: LiquidSubstitutionsStrings = {}

  /**
   * The resolved dependencies after substitutions.
   *
   * @remarks
   * This object contains the final merged dependencies after inheritance
   * resolution and Liquid template substitution.
   *
   * Dependency resolution workflow:
   *
   * <ol>
   * <li>Start with empty object.</li>
   * <li>Merge <code>dependencies</code> from each inherited configuration
   *    in sequence
   *    (later overrides earlier).</li>
   * <li>Merge local <code>dependencies</code> from JSON definition.</li>
   * <li>Perform Liquid template substitution on the entire
   *    <code>dependencies</code>
   *    object with full configuration context (properties, matrix, etc.).</li>
   * </ol>
   *
   * This enables configuration-specific dependencies with dynamic version
   * ranges or package selection based on matrix parameters, platform
   * detection, or configuration properties.
   */
  dependencies: JsonDependencies = {}

  /**
   * The resolved development dependencies after substitutions.
   *
   * @remarks
   * This object contains the final merged development dependencies after
   * inheritance resolution and Liquid template substitution.
   *
   * Resolution workflow mirrors `dependencies`:
   *
   * <ol>
   * <li>Start with empty object.</li>
   * <li>Merge <code>devDependencies</code> from each inherited configuration
   *    in sequence
   *    (later overrides earlier).</li>
   * <li>Merge local <code>devDependencies</code> from JSON definition.</li>
   * <li>Perform Liquid template substitution on the entire
   *    <code>devDependencies</code>
   *    object with full configuration context.</li>
   * </ol>
   *
   * Typical use: Test frameworks, build tools, or debugging utilities
   * specific to certain configurations (e.g., debug builds might include
   * additional analysis tools).
   */
  devDependencies: JsonDependencies = {}

  /**
   * The JSON build configuration content from package metadata.
   *
   * @remarks
   * This holds the raw configuration definition as it appears in
   * `package.json`, before inheritance resolution and variable substitution.
   *
   * The definition is preserved to:
   *
   * <ol>
   * <li>Enable external modification (e.g., <code>xpm uninstall</code>
   *    updates this
   *    directly).</li>
   * <li>Support deferred template evaluation during
   *    <code>BuildConfiguration.initialise()</code>.</li>
   * <li>Provide the source for inheritance when other configurations
   *    reference this one.</li>
   * <li>Allow re-evaluation with different variable contexts if needed.</li>
   * </ol>
   *
   * This immutable storage ensures configurations can be safely referenced
   * during inheritance resolution without side effects.
   */
  jsonBuildConfiguration: JsonBuildConfigurationContent

  /**
   * Indicates whether this configuration originates from a template.
   *
   * @remarks
   * This flag determines the substitution strategy during configuration
   * initialisation, with template configurations requiring more extensive
   * processing.
   *
   * Template vs regular configuration processing:
   *
   * <ol>
   * <li>Template configurations (<code>isTemplate === true</code>):
   *   <ul>
   *   <li>Entire JSON configuration is stringified and substituted.</li>
   *   <li>Matrix parameters available throughout all fields.</li>
   *   <li>More expensive but supports matrix references anywhere.</li>
   *   </ul>
   * </li>
   * <li>Regular configurations (<code>isTemplate === false</code>):
   *   <ul>
   *   <li>Only <code>inherits</code> field is substituted initially.</li>
   *   <li>Other fields processed selectively during inheritance
   *     resolution.</li>
   *   <li>More efficient for configurations without matrix parameters.</li>
   *   </ul>
   * </li>
   * </ol>
   *
   * Set to `true` when `templateBuildConfigurationName` is defined,
   * indicating the configuration was generated from a template expansion.
   */
  isTemplate: boolean

  // --------------------------------------------------------------------------
  // Protected Members.

  /**
   * The logger instance for output and diagnostics.
   *
   * @remarks
   * This logger provides trace-level diagnostics throughout the build
   * configuration lifecycle, including template substitution, inheritance
   * resolution, property merging, dependency substitution, and action
   * preparation.
   *
   * It is initialised in the constructor from the parent collection's logger
   * and used consistently across all helper methods to maintain coherent
   * logging output. This enables detailed debugging of complex configuration
   * hierarchies without impacting runtime performance when tracing is
   * disabled.
   */
  protected readonly _log: Logger

  /**
   * The variables used for substitution in this configuration.
   *
   * @remarks
   * This extended variable hierarchy combines the base collection variables
   * with configuration-specific context, enabling accurate template
   * evaluation.
   *
   * Extension hierarchy:
   *
   * <ol>
   * <li>Starts with parent collection's base variables (env, os, path,
   *    package).</li>
   * <li>Extended with <code>properties</code>: Merged from inheritance
   *    chain and local
   *    properties.</li>
   * <li>Extended with <code>matrix</code>: Parameter values for
   *    template-generated
   *    configurations.</li>
   * <li>Extended with <code>configuration</code>: The configuration
   *    object itself
   *    (name, dependencies, properties) accessible for self-reference.</li>
   * </ol>
   *
   * This complete context is used for all substitutions within the
   * configuration: properties, dependencies, devDependencies, and actions.
   */
  protected _substitutionsVariables: LiquidSubstitutionsVariables

  /**
   * The matrix parameter values for template-generated configurations.
   *
   * @remarks
   * For template-generated configurations, this object contains the specific
   * matrix parameter values that produced this configuration instance from
   * the template.
   *
   * Usage pattern:
   *
   * <ol>
   * <li>Undefined for regular (non-template) configurations.</li>
   * <li>For template configurations, contains key-value pairs from the matrix
   *    combination (e.g.,
   *   <code>\{ arch: 'x64', optimize: 'speed' \}</code>).</li>
   * <li>Merged into substitution variables during initialisation, making
   *    values accessible via the <code>matrix</code> namespace throughout the
   *    configuration.</li>
   * <li>Used in configuration name substitution, property values,
   *    dependencies, and action commands.</li>
   * </ol>
   *
   * Example: A template `release-{{ matrix.arch }}` with matrix parameters
   * `{ arch: 'x64' }` becomes the concrete configuration `release-x64`.
   */
  protected readonly matrixParameters?: LiquidSubstitutionsStrings

  /**
   * The actions associated with this build configuration.
   *
   * @remarks
   * This actions collection is created during configuration initialisation
   * and combines inherited actions with local action definitions.
   *
   * Action assembly workflow:
   *
   * <ol>
   * <li>Undefined until <code>BuildConfiguration.initialise()</code> is
   *    called.</li>
   * <li>Collect actions from all inherited configurations in the inheritance
   *    chain.</li>
   * <li>Create new <code>Actions</code> collection with inherited
   *    actions map and local action definitions.</li>
   * <li>Actions inherit the configuration's substitution variables context,
   *    including properties and matrix parameters.</li>
   * </ol>
   *
   * Actions are accessible after configuration initialisation but remain
   * themselves uninitialised until retrieved and initialised individually,
   * maintaining the lazy evaluation pattern.
   */
  protected _actions: Actions | undefined

  /**
   * The resolved build folder relative path.
   *
   * @remarks
   * This path specifies where build outputs for this configuration should be
   * placed, computed during initialisation and added back to properties for
   * use in subsequent substitutions.
   *
   * Computation workflow:
   *
   * <ol>
   * <li>Undefined until <code>BuildConfiguration.initialise()</code> is
   *    called.</li>
   * <li>Not computed for hidden configurations (optimization).</li>
   * <li>If <code>buildFolderRelativePath</code> property exists, perform Liquid
   *    substitution with full configuration context.</li>
   * <li>Otherwise, generate default path:
   *    <code>build/\{sanitized-config-name\}</code>.</li>
   * <li>Added to <code>properties.buildFolderRelativePath</code> for use
   *    in action
   *    commands and dependency references.</li>
   * </ol>
   *
   * The path is relative to the package root and used by build tools to
   * organize outputs from different configurations.
   */
  protected _buildFolderRelativePath?: string

  /**
   * Set of inherited configuration names for circular reference detection.
   *
   * @remarks
   * This set tracks the inheritance chain being processed to detect and
   * prevent circular inheritance references.
   *
   * Detection mechanism:
   *
   * <ol>
   * <li>Initially empty when configuration initialisation begins.</li>
   * <li>Each inherited configuration name is added before processing that
   *    configuration's inheritance.</li>
   * <li>If a configuration attempts to inherit from a name already in the
   *    set, a circular reference exists.</li>
   * <li>Circular references trigger <code>InputError</code> with details
   * about    the problematic inheritance chain.</li>
   * </ol>
   *
   * Example: If config A inherits from B, B from C, and C from A, the
   * circular dependency is detected when C attempts to inherit from A.
   */
  protected _inheritedNamesSet: Set<string> = new Set<string>()

  /**
   * Flag indicating whether the configuration has been initialised.
   *
   * @remarks
   * This flag ensures idempotent initialization and prevents redundant
   * processing when {@link BuildConfiguration.initialise} is called
   * multiple times.
   *
   * State transitions:
   *
   * <ol>
   * <li>Initially <code>false</code> after construction.</li>
   * <li>Set to <code>true</code> after successful inheritance resolution,
   *    property
   *    merging, dependency substitution, and action preparation.</li>
   * <li>Checked at the start of
   *    <code>BuildConfiguration.initialise()</code> to return early if
   *    already initialised.</li>
   * </ol>
   *
   * This pattern is critical for inheritance processing, as configurations
   * may be initialised multiple times when referenced by multiple children,
   * but should only process their inheritance chain once.
   */
  protected _isInitialised = false

  // --------------------------------------------------------------------------
  // Constructor and async initialiser.

  /**
   * Constructs a build configuration instance.
   *
   * @param buildConfigurationName - The configuration name after substitution.
   * @param templateBuildConfigurationName - The template configuration name, if
   * derived from a template.
   * @param jsonBuildConfiguration - The JSON configuration definition.
   * @param parentBuildConfigurations - The parent configurations collection.
   * @param matrixParameters - Optional matrix parameter values for
   * template-generated configurations.
   *
   * @remarks
   * The constructor performs partial initialisation. Full initialisation
   * requires calling {@link BuildConfiguration.initialise}.
   */
  constructor({
    buildConfigurationName,
    templateBuildConfigurationName,
    jsonBuildConfiguration,
    parentBuildConfigurations,
    matrixParameters,
  }: BuildConfigurationConstructorParameters) {
    assert(buildConfigurationName, 'buildConfigurationName is required')
    assert(jsonBuildConfiguration, 'jsonBuildConfiguration is required')
    assert(parentBuildConfigurations, 'parentBuildConfigurations is required')

    const log = parentBuildConfigurations.log
    this._log = log

    log.trace(`${BuildConfiguration.name}(${buildConfigurationName})`)

    this.name = buildConfigurationName
    this.jsonBuildConfiguration = jsonBuildConfiguration
    this.parentBuildConfigurations = parentBuildConfigurations
    if (matrixParameters !== undefined) {
      this.matrixParameters = matrixParameters
    }
    if (templateBuildConfigurationName !== undefined) {
      this.templateName = templateBuildConfigurationName
    }

    this._substitutionsVariables = {
      ...this.parentBuildConfigurations.substitutionsVariables,
    }

    this.isHidden = this.jsonBuildConfiguration.hidden ?? false

    this.isTemplate = this.templateName !== undefined

    // The rest of the initialisation is done in the async initialiser.
  }

  /**
   * Completes the async initialisation of the build configuration.
   *
   * @remarks
   * This method resolves inheritance, applies variable substitutions,
   * computes dependencies, and prepares actions.
   *
   * Initialisation workflow:
   *
   * <ol>
   * <li>Return early if already initialised (idempotent behaviour).</li>
   * <li>For template configurations: Call
   *    <code>_substituteTemplate()</code> to substitute
   *    matrix parameters throughout the entire JSON structure.</li>
   * <li>For non-template configurations: Call
   *    <code>_substituteInherits()</code> to substitute
   *    only the inherits field.</li>
   * <li>Call <code>_processInherits()</code> to:
   *   <ul>
   *   <li>Process inheritance chain recursively with circular reference
   *      detection.</li>
   *   <li>Merge properties, dependencies, and devDependencies from inherited
   *      configurations (later overrides earlier).</li>
   *   <li>Collect inherited actions into a map.</li>
   *   </ul>
   * </li>
   * <li>Apply local properties and update substitution variables context.</li>
   * <li>For visible configurations: Compute build folder relative path via
   *    <code>_getBuildFolderRelativePath()</code>.</li>
   * <li>Substitute Liquid templates in dependencies and devDependencies.</li>
   * <li>Create actions collection with inherited actions and local
   * actions.</li>
   * </ol>
   *
   * The substitution context includes package variables, configuration
   * properties, matrix parameters (for templates), and the configuration
   * object itself accessible via `configuration.name`, etc.
   *
   * @returns A promise that resolves to `true` if initialisation was performed,
   * or `false` if already initialised.
   *
   * @throws {@link ConfigurationError}
   * If substitutions fail.
   *
   * @throws {@link InputError}
   * If inheritance references are invalid or circular.
   */
  async initialise(): Promise<boolean> {
    const log = this._log
    log.trace(`${BuildConfiguration.name}.initialise()` + ` @${this.name}`)

    if (this._isInitialised) {
      log.trace(
        `${BuildConfiguration.name}.initialise()` + ` @${this.name} again`
      )
      return false
    }

    log.trace(`${BuildConfiguration.name}.initialise()` + ` @${this.name}`)
    let localJsonBuildConfiguration: JsonBuildConfigurationContent

    if (this.isTemplate) {
      localJsonBuildConfiguration = await this._substituteTemplate()
    } else {
      localJsonBuildConfiguration = await this._substituteInherits()
    }

    // Add inherited properties, dependencies, devDependencies, and actions.
    const inheritedActionsMap = await this._processInherits(
      localJsonBuildConfiguration
    )

    this.properties = {
      ...this.properties,
      ...localJsonBuildConfiguration.properties,
    }

    assert(this.name, 'buildConfigurationName missing')
    this._substitutionsVariables = {
      ...this.parentBuildConfigurations.substitutionsVariables,
      properties: {
        ...this._substitutionsVariables.properties,
        ...this.properties,
      },
      matrix: this.matrixParameters ?? {},
      configuration: {
        ...localJsonBuildConfiguration,
        name: this.name,
      },
    }

    if (!this.isHidden) {
      this._buildFolderRelativePath = await this._getBuildFolderRelativePath()

      // Add the buildFolderRelativePath property.
      // Note: the async initialiser was needed due to this async operation.
      const properties = this._substitutionsVariables.properties
      properties.buildFolderRelativePath = this._buildFolderRelativePath
    }

    this.dependencies = {
      ...this.dependencies,
      ...localJsonBuildConfiguration.dependencies,
    }

    this.devDependencies = {
      ...this.devDependencies,
      ...localJsonBuildConfiguration.devDependencies,
    }

    const unsubstitutedDependencies = {
      dependencies: this.dependencies,
      devDependencies: this.devDependencies,
    }

    const stringifiedDependencies = JSON.stringify(unsubstitutedDependencies)

    if (hasLiquidSyntax(stringifiedDependencies)) {
      let substitutedDependencies
      try {
        substitutedDependencies = await performSubstitutions({
          log,
          engine: this.parentBuildConfigurations.engine,
          input: stringifiedDependencies,
          substitutionsVariables: this._substitutionsVariables,
        })
      } catch (error) {
        const message =
          getErrorMessage(error) +
          ` in buildConfiguration "${this.name}" dependencies`
        throw new ConfigurationError(message)
      }
      const parsedDependencies = JSON.parse(
        substitutedDependencies
      ) as JsonBuildConfigurationContent

      // Safety net: These fallbacks to empty objects handle cases where the
      // dependencies fields might be undefined after JSON parsing. This is
      // unlikely because the JSON schema validation ensures these are objects
      // when present, but provides robustness against malformed configuration
      // or future schema changes.
      /* c8 ignore start - safety net, they are always defined */
      this.dependencies = parsedDependencies.dependencies ?? {}
      this.devDependencies = parsedDependencies.devDependencies ?? {}
      /* c8 ignore stop */
    }

    this._actions = new Actions({
      log: this._log,
      engine: this.parentBuildConfigurations.engine,
      substitutionsVariables: this._substitutionsVariables,
      inheritedActionsMap,
      jsonActions: localJsonBuildConfiguration.actions,
      buildConfiguration: this,
    })

    log.trace(
      `${BuildConfiguration.name}.initialise() `,
      `@{this.buildConfigurationName}`
    )

    if (!this.isHidden) {
      log.trace(
        this.name,
        'buildFolderRelativePath =>',
        this._buildFolderRelativePath
      )
    }
    log.trace(this.name, 'properties => ', this.properties)
    log.trace(this.name, 'dependencies => ', this.dependencies)
    log.trace(this.name, 'devDependencies => ', this.devDependencies)

    // Action names are not available at this point.
    // log.trace(this.buildConfigurationName, 'actions => ',
    // this._actions.names)

    this._isInitialised = true
    return true
  }

  // --------------------------------------------------------------------------
  // Public Methods.

  /**
   * Retrieves the actions collection for this build configuration.
   *
   * @returns The actions collection.
   */
  get actions(): Actions {
    assert(
      this._isInitialised,
      'BuildConfiguration must be initialised before ' + 'accessing actions'
    )

    assert(this._actions !== undefined, 'Actions not initialised')
    return this._actions
  }

  /**
   * Retrieves the build folder relative path for this configuration.
   *
   * @returns The build folder relative path.
   */
  get buildFolderRelativePath(): string {
    assert(
      this._isInitialised,
      'BuildConfiguration must be initialised before ' +
        'accessing buildFolderRelativePath'
    )
    assert(
      this._buildFolderRelativePath !== undefined,
      'BuildConfiguration _buildFolderRelativePath not initialised'
    )
    return this._buildFolderRelativePath
  }

  // --------------------------------------------------------------------------
  // Private Methods.

  /**
   * Performs template substitution on the entire build configuration JSON.
   *
   * @remarks
   * This method is invoked during initialisation for template-generated
   * configurations to substitute matrix parameters throughout the entire
   * configuration definition.
   *
   * Processing steps:
   *
   * <ol>
   * <li>Stringify the entire JSON build configuration object.</li>
   * <li>Check if the stringified JSON contains template syntax
   *    (<code>\{\{</code> or <code>\{%</code>).</li>
   * <li>If templates are found:
   *   <ul>
   *   <li>Perform Liquid substitutions with complete variable context
   *      including matrix parameters.</li>
   *   <li>Parse the substituted JSON string back into an object.</li>
   *   </ul>
   * </li>
   * <li>If no templates are found, return the original configuration
   *    as-is.</li>
   * </ol>
   *
   * This comprehensive substitution approach ensures matrix parameters can
   * be referenced anywhere within the configuration (properties, dependencies,
   * actions, etc.), which is necessary for template-generated configurations
   * but would be unnecessarily expensive for regular configurations.
   *
   * @returns A promise that resolves to the build configuration content with
   * all template variables substituted.
   *
   * @throws {@link ConfigurationError}
   * If Liquid template substitution fails.
   */
  // eslint-disable-next-line max-len
  protected async _substituteTemplate(): Promise<JsonBuildConfigurationContent> {
    const log = this._log

    // For templates, perform substitutions on the entire build
    // configuration JSON, since there can be matrix references everywhere.

    let localJsonBuildConfiguration: JsonBuildConfigurationContent

    const stringifiedJsonBuildConfiguration = JSON.stringify(
      this.jsonBuildConfiguration
    )
    if (hasLiquidSyntax(stringifiedJsonBuildConfiguration)) {
      let substitutedJsonBuildConfiguration
      try {
        substitutedJsonBuildConfiguration = await performSubstitutions({
          log,
          engine: this.parentBuildConfigurations.engine,
          input: stringifiedJsonBuildConfiguration,
          substitutionsVariables: {
            ...this._substitutionsVariables,
            // Safety net: This fallback ensures matrix is always an object.
            // matrixParameters should be defined when processing templates with
            // matrix expansion, but this handles edge cases where
            // initialisation
            // order or template logic might reference matrix before it's set.
            /* c8 ignore start - safety net, they are always defined */
            matrix: this.matrixParameters ?? {},
            /* c8 ignore stop */
            configuration: {
              ...this.jsonBuildConfiguration,
              name: this.name,
            },
          },
        })
      } catch (error) {
        const message =
          getErrorMessage(error) + ` in buildConfiguration "${this.name}"`
        throw new ConfigurationError(message)
      }

      localJsonBuildConfiguration = JSON.parse(
        substitutedJsonBuildConfiguration
      ) as JsonBuildConfigurationContent
    } else {
      localJsonBuildConfiguration = this.jsonBuildConfiguration
    }
    return localJsonBuildConfiguration
  }

  /**
   * Performs selective substitution on the inherits field only.
   *
   * @remarks
   * This method is invoked during initialisation for regular (non-template)
   * configurations to substitute template variables in the inheritance
   * specification whilst leaving other fields untouched until later processing.
   *
   * Processing steps:
   *
   * <ol>
   * <li>Extract the <code>inherits</code> (or deprecated
   *    <code>inherit</code>) field from the
   *    configuration.</li>
   * <li>Stringify the inherits field and check for template syntax
   *    (<code>\{\{</code> or <code>\{%</code>).</li>
   * <li>If templates are found:
   *   <ul>
   *   <li>Perform Liquid substitutions with current variable context.</li>
   *   <li>Parse the substituted JSON string back into an object.</li>
   *   <li>Return a new configuration object with the substituted inherits
   *      field and all other fields unchanged.</li>
   *   </ul>
   * </li>
   * <li>If no templates are found, return the original configuration
   *    as-is.</li>
   * </ol>
   *
   * This selective approach is more efficient than full JSON substitution
   * for regular configurations that do not have matrix parameters. The
   * remaining fields (properties, dependencies, actions) are processed
   * during inheritance resolution and dependency substitution phases.
   *
   * @returns A promise that resolves to the build configuration content with
   * the inherits field substituted.
   *
   * @throws {@link ConfigurationError}
   * If Liquid template substitution fails on the inherits field.
   */
  // eslint-disable-next-line max-len
  protected async _substituteInherits(): Promise<JsonBuildConfigurationContent> {
    const log = this._log

    let localJsonBuildConfiguration: JsonBuildConfigurationContent

    // For non-templates, first perform substitutions on 'inherits' only.
    // The rest of the entries are collected as-is and processed later.
    const stringifiedJsonInherits = JSON.stringify(
      this.jsonBuildConfiguration.inherits ?? {}
    )
    if (hasLiquidSyntax(stringifiedJsonInherits)) {
      let substitutedJsonInherits
      try {
        substitutedJsonInherits = await performSubstitutions({
          log,
          engine: this.parentBuildConfigurations.engine,
          input: stringifiedJsonInherits,
          substitutionsVariables: {
            ...this._substitutionsVariables,
            configuration: {
              ...this.jsonBuildConfiguration,
              name: this.name,
            },
          },
        })
      } catch (error) {
        const message =
          getErrorMessage(error) +
          ` in buildConfiguration "${this.name}" inherits`
        throw new ConfigurationError(message)
      }

      localJsonBuildConfiguration = {
        ...this.jsonBuildConfiguration,
        inherits: JSON.parse(
          substitutedJsonInherits
        ) as JsonBuildConfigurationInherits,
      }
    } else {
      localJsonBuildConfiguration = this.jsonBuildConfiguration
    }
    return localJsonBuildConfiguration
  }

  /**
   * Parses the inherits field from JSON configuration.
   *
   * @remarks
   * This helper method extracts and normalises inheritance information from
   * the configuration, supporting both the current <code>inherits</code>
   * field and the deprecated <code>inherit</code> field. It handles both
   * string and array formats.
   *
   * Processing steps:
   *
   * <ol>
   * <li>Check for <code>inherits</code> field (current standard).</li>
   * <li>Fall back to <code>inherit</code> field (deprecated).</li>
   * <li>Convert single strings to single-element arrays.</li>
   * <li>Join array elements with line breaks and split to handle
   *    multi-line strings.</li>
   * </ol>
   *
   * @param localJsonBuildConfiguration - The JSON configuration content.
   * @returns Array of inherited configuration names.
   */
  private _parseInheritsField(
    localJsonBuildConfiguration: JsonBuildConfigurationContent
  ): string[] {
    let jsonInherits: string[] = []
    if (isString(localJsonBuildConfiguration.inherits)) {
      jsonInherits = [localJsonBuildConfiguration.inherits as string]
    } else if (Array.isArray(localJsonBuildConfiguration.inherits)) {
      jsonInherits = localJsonBuildConfiguration.inherits as string[]
    } else if (isString(localJsonBuildConfiguration.inherit)) {
      jsonInherits = [localJsonBuildConfiguration.inherit as string]
    } else if (Array.isArray(localJsonBuildConfiguration.inherit)) {
      jsonInherits = localJsonBuildConfiguration.inherit as string[]
    }

    if (jsonInherits.length > 0) {
      const joinedInherits = jsonInherits.join(os.EOL)
      return joinedInherits.split(os.EOL)
    }
    return jsonInherits
  }

  /**
   * Processes and merges a single inherited configuration.
   *
   * @remarks
   * This helper method handles the initialisation of a single inherited
   * configuration and merges its properties, dependencies, and actions into
   * the current configuration.
   *
   * Processing steps:
   *
   * <ol>
   * <li>Detect circular references by checking the inherited names set.</li>
   * <li>Initialise the inherited configuration recursively.</li>
   * <li>Merge properties, dependencies, and devDependencies using spread
   *    operator (later values override earlier ones).</li>
   * <li>Collect inherited actions into the provided map.</li>
   * </ol>
   *
   * @param inheritedBuildConfigurationName - Name of the configuration to
   * inherit from.
   * @param inheritedActionsMap - Map to accumulate inherited actions.
   * @returns A promise that resolves when processing is complete.
   *
   * @throws {@link InputError}
   * If a circular inheritance reference is detected.
   *
   * @throws {@link InputError}
   * If the inherited configuration name does not exist.
   */
  private async _processInheritedConfiguration(
    inheritedBuildConfigurationName: string,
    inheritedActionsMap: Map<string, Action>
  ): Promise<void> {
    if (inheritedBuildConfigurationName.trim() === '') {
      return
    }

    if (
      !this.parentBuildConfigurations.hasJson(inheritedBuildConfigurationName)
    ) {
      throw new ConfigurationError(
        `buildConfiguration "${this.name}" ` +
          `inherits from missing "${inheritedBuildConfigurationName}"`
      )
    }

    if (this._inheritedNamesSet.has(inheritedBuildConfigurationName)) {
      throw new ConfigurationError(
        `buildConfiguration "${this.name}" ` +
          `inherits from circular reference ` +
          `"${inheritedBuildConfigurationName}"`
      )
    }

    this._inheritedNamesSet.add(inheritedBuildConfigurationName)

    const inheritedBuildConfiguration = this.parentBuildConfigurations.get(
      inheritedBuildConfigurationName
    )

    await inheritedBuildConfiguration.initialise()

    // Merge properties, dependencies, devDependencies.
    // Later ones override earlier ones.
    this.properties = {
      ...this.properties,
      ...inheritedBuildConfiguration.properties,
    }

    this.dependencies = {
      ...this.dependencies,
      ...inheritedBuildConfiguration.dependencies,
    }

    this.devDependencies = {
      ...this.devDependencies,
      ...inheritedBuildConfiguration.devDependencies,
    }

    await inheritedBuildConfiguration.actions.initialise()
    for (const actionName of inheritedBuildConfiguration.actions.names) {
      const action = inheritedBuildConfiguration.actions.get(actionName)
      inheritedActionsMap.set(actionName, action)
    }
  }

  /**
   * Processes inheritance for a build configuration.
   *
   * @remarks
   * This method implements the inheritance resolution mechanism for build
   * configurations, enabling configurations to share properties, dependencies,
   * and actions by inheriting from one or more base configurations,
   * recursively.
   *
   * Processing workflow:
   *
   * <ol>
   * <li>Extract inheritance specification from the configuration:
   *   <ul>
   *   <li>Supports both the current <code>inherits</code> field and the
   *      deprecated <code>inherit</code> field for backwards
   *      compatibility.</li>
   *   <li>Handles both string format (single parent) and array format
   *      (multiple parents).</li>
   *   <li>Normalises line-separated names within array elements to support
   *      multi-line specifications.</li>
   *   </ul>
   * </li>
   * <li>Process each inherited configuration sequentially:
   *   <ul>
   *   <li>Skip empty names from the inheritance list.</li>
   *   <li>Validate that the inherited configuration exists in the parent
   *      collection.</li>
   *   <li>Detect circular references by checking
   *      <code>_inheritedNamesSet</code>.</li>
   *   <li>Recursively initialise the inherited configuration (which may
   *      itself have inheritance).</li>
   *   </ul>
   * </li>
   * <li>Merge inherited content into the current configuration:
   *   <ul>
   *   <li>Properties: Later inherited configurations override earlier ones,
   *      local properties override all inherited.</li>
   *   <li>Dependencies and devDependencies: Same override behaviour as
   *      properties.</li>
   *   <li>Actions: Collected into a map where later definitions override
   *      earlier ones with the same name.</li>
   *   </ul>
   * </li>
   * </ol>
   *
   * The inheritance chain is processed depth-first, ensuring that transitive
   * inheritance (A inherits B, B inherits C) is fully resolved before merging
   * properties. Circular references are detected to prevent infinite recursion.
   *
   * @param localJsonBuildConfiguration - The JSON configuration content after
   * template or inherits field substitution.
   * @returns A promise that resolves to a map of inherited actions, where
   * keys are action names and values are action instances from all inherited
   * configurations.
   *
   * @throws {@link InputError}
   * If an inherited configuration name does not exist in the parent collection.
   *
   * @throws {@link InputError}
   * If a circular inheritance reference is detected.
   */
  protected async _processInherits(
    localJsonBuildConfiguration: JsonBuildConfigurationContent
  ): Promise<Map<string, Action>> {
    const log = this._log

    const inheritsNames = this._parseInheritsField(localJsonBuildConfiguration)
    this.inheritsNames = inheritsNames

    log.trace(this.name, 'inherits from', this.inheritsNames)

    const inheritedActionsMap: Map<string, Action> = new Map<string, Action>()

    for (const inheritedBuildConfigurationName of inheritsNames) {
      await this._processInheritedConfiguration(
        inheritedBuildConfigurationName,
        inheritedActionsMap
      )
    }

    return inheritedActionsMap
  }

  /**
   * Computes the build folder relative path for this configuration.
   *
   * @remarks
   * This method resolves the build folder relative path property when
   * provided and uses a default value based on the configuration name
   * otherwise.
   *
   * Resolution strategy:
   *
   * <ol>
   * <li>Check if buildFolderRelativePath property exists in configuration
   *    properties.</li>
   * <li>If present and non-empty, perform Liquid substitutions with the
   *    full configuration context.</li>
   * <li>If substitution fails or property is empty/missing, generate a
   *    default path: `build/{filtered-configuration-name}` where the
   *    configuration name is sanitized for filesystem compatibility.</li>
   * </ol>
   *
   * The computed path is added back to the properties as
   * `buildFolderRelativePath` for use in subsequent substitutions.
   *
   * @returns A promise that resolves to the build folder relative path.
   */
  protected async _getBuildFolderRelativePath(): Promise<string> {
    const log = this._log

    let folderPath: string
    if (
      buildFolderRelativePathPropertyName in
      this._substitutionsVariables.properties
    ) {
      folderPath = this._substitutionsVariables.properties[
        buildFolderRelativePathPropertyName
      ] as string
      if (folderPath !== '') {
        try {
          // log.trace(this.#substitutionsVariables.configuration)
          const substitutedFolderPath = await performSubstitutions({
            log,
            engine: this.parentBuildConfigurations.engine,
            input: folderPath,
            substitutionsVariables: this._substitutionsVariables,
          })
          return substitutedFolderPath
        } catch (error) {
          const message =
            getErrorMessage(error) + ` in buildConfiguration "${this.name}"`
          throw new ConfigurationError(message)
        }
      }
    }

    // Provide a default value, based on the name.
    const defaultFolderPath = path.join('build', filterPath(this.name))
    return defaultFolderPath
  }
}

// ----------------------------------------------------------------------------