// SPDX-License-Identifier: Apache-2.0

import fs from 'node:fs';
import path from 'node:path';
import {SoloError} from './errors/solo-error.js';
import {IllegalArgumentError} from './errors/illegal-argument-error.js';
import {MissingArgumentError} from './errors/missing-argument-error.js';
import * as yaml from 'yaml';
import dot from 'dot-object';
import {readFile, writeFile} from 'node:fs/promises';

import {Flags as flags} from '../commands/flags.js';
import {Templates} from './templates.js';
import * as constants from './constants.js';
import {type ConfigManager} from './config-manager.js';
import * as helpers from './helpers.js';
import {type SoloLogger} from './logging/solo-logger.js';
import {type AnyObject, type DirectoryPath, type NodeAlias, type NodeAliases, type Path} from '../types/aliases.js';
import {type Optional} from '../types/index.js';
import {inject, injectable} from 'tsyringe-neo';
import {patchInject} from './dependency-injection/container-helper.js';
import {InjectTokens} from './dependency-injection/inject-tokens.js';
import {type ConsensusNode} from './model/consensus-node.js';
import {type K8Factory} from '../integration/kube/k8-factory.js';
import {type K8} from '../integration/kube/k8.js';
import {ContainerReference} from '../integration/kube/resources/container/container-reference.js';
import {type Pod} from '../integration/kube/resources/pod/pod.js';
import {type PodReference} from '../integration/kube/resources/pod/pod-reference.js';
import {type Container} from '../integration/kube/resources/container/container.js';
import {type ConfigMap} from '../integration/kube/resources/config-map/config-map.js';
import {type ClusterReferenceName, DeploymentName, Realm, Shard} from './../types/index.js';
import {PathEx} from '../business/utils/path-ex.js';
import {AccountManager} from './account-manager.js';
import {LocalConfigRuntimeState} from '../business/runtime-state/config/local/local-config-runtime-state.js';
import {type RemoteConfigRuntimeStateApi} from '../business/runtime-state/api/remote-config-runtime-state-api.js';
import {BlockNodeStateSchema} from '../data/schema/model/remote/state/block-node-state-schema.js';
import {BlockNodesJsonWrapper} from './block-nodes-json-wrapper.js';
import {NamespaceName} from '../types/namespace/namespace-name.js';
import {Address} from '../business/address/address.js';
import * as versions from '../../version.js';
import {Numbers} from '../business/utils/numbers.js';
import {SemanticVersion} from '../business/utils/semantic-version.js';

export interface ProfileManagerStagingOptions {
  // These values are intentionally passed from the command's resolved config so profile generation
  // does not depend on mutable global flags that can be changed by concurrently running subcommands.
  cacheDir: DirectoryPath;
  releaseTag: string;
  appName: string;
  chainId: string;
}

@injectable()
export class ProfileManager {
  private readonly logger: SoloLogger;
  private readonly configManager: ConfigManager;
  private readonly cacheDir: DirectoryPath;
  private readonly k8Factory: K8Factory;
  private readonly remoteConfig: RemoteConfigRuntimeStateApi;
  private readonly accountManager: AccountManager;
  private readonly localConfig: LocalConfigRuntimeState;

  public constructor(
    @inject(InjectTokens.SoloLogger) logger?: SoloLogger,
    @inject(InjectTokens.ConfigManager) configManager?: ConfigManager,
    @inject(InjectTokens.CacheDir) cacheDirectory?: DirectoryPath,
    @inject(InjectTokens.K8Factory) k8Factory?: K8Factory,
    @inject(InjectTokens.RemoteConfigRuntimeState) remoteConfig?: RemoteConfigRuntimeStateApi,
    @inject(InjectTokens.AccountManager) accountManager?: AccountManager,
    @inject(InjectTokens.LocalConfigRuntimeState) localConfig?: LocalConfigRuntimeState,
  ) {
    this.logger = patchInject(logger, InjectTokens.SoloLogger, this.constructor.name);
    this.configManager = patchInject(configManager, InjectTokens.ConfigManager, this.constructor.name);
    this.cacheDir = PathEx.resolve(patchInject(cacheDirectory, InjectTokens.CacheDir, this.constructor.name));
    this.k8Factory = patchInject(k8Factory, InjectTokens.K8Factory, this.constructor.name);
    this.remoteConfig = patchInject(remoteConfig, InjectTokens.RemoteConfigRuntimeState, this.constructor.name);
    this.accountManager = patchInject(accountManager, InjectTokens.AccountManager, this.constructor.name);
    this.localConfig = patchInject(localConfig, InjectTokens.LocalConfigRuntimeState, this.constructor.name);
  }

  /**
   * Set value in the YAML object
   * @param itemPath - item path in the yaml
   * @param value - value to be set
   * @param yamlRoot - root of the YAML object
   * @returns
   */
  public _setValue(itemPath: string, value: unknown, yamlRoot: AnyObject): AnyObject {
    // find the location where to set the value in the YAML
    const itemPathParts: string[] = itemPath.split('.');
    let parent: AnyObject = yamlRoot;
    let current: AnyObject = parent;
    let previousItemPath: string | number = '';
    for (const itemPathPart of itemPathParts) {
      if (Numbers.isNumeric(itemPathPart)) {
        const itemPathIndex: number = Number.parseInt(itemPathPart, 10); // numeric path part can only be array index
        if (!Array.isArray(parent[previousItemPath])) {
          parent[previousItemPath] = [];
        }

        const parentArray: AnyObject[] = parent[previousItemPath] as AnyObject[];
        if (!parentArray[itemPathIndex]) {
          parentArray[itemPathIndex] = {};
        }

        parent = parentArray as unknown as AnyObject;
        previousItemPath = itemPathIndex;
        current = parent[itemPathIndex] as AnyObject;
      } else {
        if (!current[itemPathPart]) {
          current[itemPathPart] = {};
        }

        parent = current;
        previousItemPath = itemPathPart;
        current = parent[itemPathPart];
      }
    }

    parent[previousItemPath] = value;
    return yamlRoot;
  }

  /**
   * Set items for the chart
   * @param itemPath - item path in the YAML, if empty then root of the YAML object will be used
   * @param items - the element object
   * @param yamlRoot - root of the YAML object to update
   */
  public _setChartItems(itemPath: string, items: AnyObject | undefined, yamlRoot: AnyObject): void {
    if (!items) {
      return;
    }

    const dotItems: AnyObject = dot.dot(items) as AnyObject;

    for (const key in dotItems) {
      let itemKey: string = key;

      // if it is an array key like extraEnvironment[0].JAVA_OPTS, convert it into a dot separated key as extraEnvironment.0.JAVA_OPTS
      if (key.includes('[')) {
        itemKey = key.replace('[', '.').replace(']', '');
      }

      if (itemPath) {
        this._setValue(`${itemPath}.${itemKey}`, dotItems[key], yamlRoot);
      } else {
        this._setValue(itemKey, dotItems[key], yamlRoot);
      }
    }
  }

  public async prepareStagingDirectory(
    consensusNodes: ConsensusNode[],
    nodeAliases: NodeAliases,
    yamlRoot: AnyObject,
    deploymentName: DeploymentName,
    applicationPropertiesPath: string,
    stagingOptions?: Partial<ProfileManagerStagingOptions>,
  ): Promise<void> {
    const accountMap: Map<NodeAlias, string> = this.accountManager.getNodeAccountMap(
      consensusNodes.map((node): NodeAlias => node.name),
      deploymentName,
    );

    // set consensus pod level resources
    for (const [nodeIndex, nodeAlias] of nodeAliases.entries()) {
      this._setValue(`hedera.nodes.${nodeIndex}.name`, nodeAlias, yamlRoot);
      this._setValue(`hedera.nodes.${nodeIndex}.nodeId`, `${Templates.nodeIdFromNodeAlias(nodeAlias)}`, yamlRoot);
      this._setValue(`hedera.nodes.${nodeIndex}.accountId`, accountMap.get(nodeAlias), yamlRoot);
    }

    // Resolve once and keep immutable for this invocation to prevent races from global flag mutation
    // while a parallel command is generating staging/config artifacts.
    const resolvedStagingOptions: ProfileManagerStagingOptions = this.resolveStagingOptions(stagingOptions);
    const stagingDirectory: string = Templates.renderStagingDir(
      resolvedStagingOptions.cacheDir,
      resolvedStagingOptions.releaseTag,
    );

    if (!fs.existsSync(stagingDirectory)) {
      fs.mkdirSync(stagingDirectory, {recursive: true});
    }

    const needsConfigTxt: boolean = versions.needsConfigTxtForConsensusVersion(resolvedStagingOptions.releaseTag);
    let configTxtPath: Optional<string>;
    if (needsConfigTxt) {
      const gossipFqdnRestricted: boolean = await this.getGossipFqdnRestricted(
        consensusNodes,
        applicationPropertiesPath,
      );
      configTxtPath = await this.prepareConfigTxt(
        accountMap,
        consensusNodes,
        stagingDirectory,
        resolvedStagingOptions.releaseTag,
        resolvedStagingOptions.appName,
        resolvedStagingOptions.chainId,
        gossipFqdnRestricted,
      );
    }

    // Update application.properties with shard and realm
    await this.updateApplicationPropertiesWithRealmAndShard(
      applicationPropertiesPath,
      this.localConfig.configuration.realmForDeployment(deploymentName),
      this.localConfig.configuration.shardForDeployment(deploymentName),
    );

    await this.updateApplicationPropertiesForBlockNode(applicationPropertiesPath);
    await this.updateApplicationPropertiesWithChainId(applicationPropertiesPath, resolvedStagingOptions.chainId);

    for (const flag of flags.nodeConfigFileFlags.values()) {
      const sourceFilePath: string = this.configManager.getFlagFile(flag);
      const currentWorkingDirectory: string = process.env.INIT_CWD || process.cwd();
      const sourceAbsoluteFilePath: string = PathEx.resolve(currentWorkingDirectory, sourceFilePath);
      if (!fs.existsSync(sourceAbsoluteFilePath)) {
        throw new SoloError(
          `Configuration file does not exist for: ${flag.name}, absolute path: ${sourceAbsoluteFilePath}, path: ${sourceFilePath}`,
        );
      }

      const destinationFileName: string = path.basename(flag.definition.defaultValue as string);
      const destinationPath: string = PathEx.join(stagingDirectory, 'templates', destinationFileName);
      this.logger.debug(`Copying configuration file to staging: ${sourceAbsoluteFilePath} -> ${destinationPath}`);

      // For application.properties: when the user provides a custom file (flag value differs
      // from the default relative path), use the user's file as the base and then apply
      // Solo's required overrides (realm, shard, block-node settings) on top.
      // This preserves all user-defined properties while ensuring Solo's critical settings win.
      const flagValue: string | undefined = this.configManager.getFlag<string>(flags.applicationProperties);
      const isUserSuppliedApplicationProperties: boolean =
        flag.name === flags.applicationProperties.name &&
        !!flagValue &&
        flagValue !== (flags.applicationProperties.definition.defaultValue as string);

      if (isUserSuppliedApplicationProperties) {
        // Base: Solo's updated default (realm/shard/block-node settings already applied).
        // Apply user's properties as key-level overrides: existing keys are updated,
        // new keys are appended.  This avoids duplicates while preserving all Solo defaults
        // that the user did not explicitly override.
        fs.cpSync(applicationPropertiesPath, destinationPath, {force: true});
        await this.mergeApplicationProperties(destinationPath, sourceAbsoluteFilePath);
      } else {
        fs.cpSync(sourceAbsoluteFilePath, destinationPath, {force: true});
      }
    }

    const bootstrapPropertiesPath: string = PathEx.join(stagingDirectory, 'templates', 'bootstrap.properties');
    await this.updateBoostrapPropertiesWithChainId(bootstrapPropertiesPath, resolvedStagingOptions.chainId);

    if (configTxtPath) {
      this._setFileContentsAsValue('hedera.configMaps.configTxt', configTxtPath, yamlRoot);
    }
    this._setFileContentsAsValue(
      'hedera.configMaps.log4j2Xml',
      PathEx.joinWithRealPath(stagingDirectory, 'templates', 'log4j2.xml'),
      yamlRoot,
    );
    this._setFileContentsAsValue(
      'hedera.configMaps.settingsTxt',
      PathEx.joinWithRealPath(stagingDirectory, 'templates', 'settings.txt'),
      yamlRoot,
    );
    this._setFileContentsAsValue(
      'hedera.configMaps.applicationProperties',
      PathEx.joinWithRealPath(stagingDirectory, 'templates', constants.APPLICATION_PROPERTIES),
      yamlRoot,
    );
    this._setFileContentsAsValue(
      'hedera.configMaps.apiPermissionsProperties',
      PathEx.joinWithRealPath(stagingDirectory, 'templates', 'api-permission.properties'),
      yamlRoot,
    );
    this._setFileContentsAsValue(
      'hedera.configMaps.bootstrapProperties',
      PathEx.joinWithRealPath(stagingDirectory, 'templates', 'bootstrap.properties'),
      yamlRoot,
    );

    const applicationEnvironmentPath: string = PathEx.join(stagingDirectory, 'templates', 'application.env');
    this._setFileContentsAsValue(
      'hedera.configMaps.applicationEnv',
      PathEx.resolve(applicationEnvironmentPath),
      yamlRoot,
    );

    try {
      if (
        this.remoteConfig.configuration.state.blockNodes.length === 0 &&
        this.remoteConfig.configuration.state.externalBlockNodes.length === 0
      ) {
        return;
      }
    } catch {
      // quick fix for tests where field on remote config are unaccessible
      return;
    }

    for (const node of consensusNodes) {
      const blockNodesJsonData: string = new BlockNodesJsonWrapper(
        node.blockNodeMap,
        node.externalBlockNodeMap,
      ).toJSON();

      let nodeIndex: number = 0;

      for (const [index, nodeAlias] of nodeAliases.entries()) {
        if (nodeAlias === node.name) {
          nodeIndex = index;
        }
      }

      // Create a unique filename for each consensus node
      const blockNodesJsonFilename: string = `${constants.BLOCK_NODES_JSON_FILE.replace('.json', '')}-${node.name}.json`;
      const blockNodesJsonPath: string = PathEx.join(constants.SOLO_CACHE_DIR, blockNodesJsonFilename);

      fs.writeFileSync(blockNodesJsonPath, JSON.stringify(JSON.parse(blockNodesJsonData), undefined, 2));
      this._setFileContentsAsValue(`hedera.nodes.${nodeIndex}.blockNodesJson`, blockNodesJsonPath, yamlRoot);
    }
  }

  /**
   * Parse a KEY=VALUE env file and override defaults.root.extraEnvironment in the Helm values
   * so that pod-level environment variables match the application.env content.
   */
  private applyApplicationEnvToExtraEnv(applicationEnvironmentPath: string, yamlRoot: AnyObject): void {
    if (!fs.existsSync(applicationEnvironmentPath)) {
      return;
    }

    const extraEnvironment: AnyObject[] = [];
    for (const line of fs.readFileSync(applicationEnvironmentPath, 'utf8').split('\n')) {
      const trimmed: string = line.trim();
      if (!trimmed || trimmed.startsWith('#')) {
        continue;
      }
      const equalsIndex: number = trimmed.indexOf('=');
      if (equalsIndex > 0) {
        extraEnvironment.push({name: trimmed.slice(0, equalsIndex), value: trimmed.slice(equalsIndex + 1)});
      }
    }

    if (extraEnvironment.length > 0) {
      this._setChartItems('defaults.root', {extraEnv: extraEnvironment}, yamlRoot);
    }
  }

  public resourcesForNetworkUpgrade(
    itemPath: string,
    fileName: string,
    stagingDirectory: string,
    yamlRoot: AnyObject,
  ): void {
    const filePath: string = PathEx.join(stagingDirectory, 'templates', fileName);

    if (!fs.existsSync(filePath)) {
      return;
    }

    this._setFileContentsAsValue(itemPath, filePath, yamlRoot);
  }

  /**
   * Prepare a values file for Solo Helm chart
   * @param consensusNodes - the list of consensus nodes
   * @param deploymentName
   * @param applicationPropertiesPath
   * @param jfrFile - the name of the custom JFR settings file to use for recording (basename only)
   * @param stagingOptions
   * @returns mapping of cluster-ref to the full path to the values file
   */
  public async prepareValuesForSoloChart(
    consensusNodes: ConsensusNode[],
    deploymentName: DeploymentName,
    applicationPropertiesPath: string,
    jfrFile: string = '',
    stagingOptions?: Partial<ProfileManagerStagingOptions>,
  ): Promise<Record<ClusterReferenceName, string>> {
    const filesMapping: Record<ClusterReferenceName, string> = {};

    for (const [clusterReference] of this.remoteConfig.getClusterRefs()) {
      const nodeAliases: NodeAliases = consensusNodes
        .filter((node): boolean => node.cluster === clusterReference)
        .map((node): NodeAlias => node.name);

      // generate the YAML
      const yamlRoot: AnyObject = {};

      await this.prepareStagingDirectory(
        consensusNodes,
        nodeAliases,
        yamlRoot,
        deploymentName,
        applicationPropertiesPath,
        stagingOptions,
      );

      // If a JFR settings file is provided, read the defaults from solo-values.yaml,
      // find the JAVA_OPTS entry in defaults.root.extraEnv, and append the
      // -XX:StartFlightRecording flags so that the recorder starts automatically
      // when the consensus node JVM launches.
      if (jfrFile !== '') {
        const soloValuesYaml: AnyObject = yaml.parse(
          fs.readFileSync(constants.SOLO_DEPLOYMENT_VALUES_FILE, 'utf8'),
        ) as AnyObject;
        const extraEnvironment: AnyObject[] = (soloValuesYaml?.defaults?.root?.extraEnv as AnyObject[]) ?? [];
        const javaOption: AnyObject | undefined = extraEnvironment.find(
          (environmentObject: AnyObject): boolean => environmentObject.name === 'JAVA_OPTS',
        );
        if (javaOption) {
          javaOption.value +=
            ' -XX:StartFlightRecording=dumponexit=true' +
            `,settings=${constants.HEDERA_HAPI_PATH}/data/config/${jfrFile}` +
            `,filename=${constants.HEDERA_HAPI_PATH}/output/recording.jfr`;
        } else {
          this.logger.warn(
            `JAVA_OPTS not found in ${constants.SOLO_DEPLOYMENT_VALUES_FILE}; JFR settings file '${jfrFile}' will not be applied`,
          );
        }
        this._setChartItems('defaults.root', soloValuesYaml.defaults.root, yamlRoot);
      }

      // Override defaults.root.extraEnv with values from the staged application.env file.
      // This must run AFTER the JFR block above, which overwrites defaults.root from solo-values.yaml.
      const stagingDirectory: string = Templates.renderStagingDir(
        this.configManager.getFlag(flags.cacheDir),
        this.configManager.getFlag(flags.releaseTag),
      );
      const applicationEnvironmentPath: string = PathEx.join(stagingDirectory, 'templates', 'application.env');
      this.applyApplicationEnvToExtraEnv(applicationEnvironmentPath, yamlRoot);

      const cachedValuesFile: string = PathEx.join(this.cacheDir, `solo-${clusterReference}.yaml`);
      filesMapping[clusterReference] = await this.writeToYaml(cachedValuesFile, yamlRoot);
    }

    return filesMapping;
  }

  private resolveStagingOptions(options?: Partial<ProfileManagerStagingOptions>): ProfileManagerStagingOptions {
    // Fallbacks preserve compatibility for call sites that do not pass explicit options yet.
    // Newer call sites should pass command-scoped values to avoid cross-command interference.
    return {
      cacheDir: options?.cacheDir ?? this.configManager.getFlag(flags.cacheDir),
      releaseTag: options?.releaseTag ?? this.configManager.getFlag(flags.releaseTag),
      appName: options?.appName ?? this.configManager.getFlag(flags.app),
      chainId: options?.chainId ?? this.configManager.getFlag(flags.chainId),
    };
  }

  private async bumpHederaConfigVersion(applicationPropertiesPath: string): Promise<void> {
    const fileContents: string = await readFile(applicationPropertiesPath, 'utf8');
    const lines: string[] = fileContents.split('\n');

    for (const line of lines) {
      if (line.startsWith('hedera.config.version=')) {
        const version: number = Number.parseInt(line.split('=')[1], 10) + 1;
        lines[lines.indexOf(line)] = `hedera.config.version=${version}`;
        break;
      }
    }

    await writeFile(applicationPropertiesPath, lines.join('\n'));
  }

  /**
   * Merge a user-supplied application.properties into the existing staging file.
   * Solo's defaults (already written to stagingPath) are the base; for each key in the
   * user's file the existing line is replaced in-place.  Keys not present in the base
   * are appended at the end.  This avoids duplicate entries while preserving every
   * Solo default the user did not explicitly override.
   */
  private async mergeApplicationProperties(stagingPath: string, userFilePath: string): Promise<void> {
    this.logger.debug(`Merging user application.properties '${userFilePath}' into staging '${stagingPath}'`);
    const stagingContent: string = await readFile(stagingPath, 'utf8');
    const userContent: string = await readFile(userFilePath, 'utf8');

    // Parse user file into key→value map (comments and blank lines are skipped)
    const userProperties: Map<string, string> = new Map<string, string>();
    for (const line of userContent.split('\n')) {
      const trimmed: string = line.trim();
      if (!trimmed || trimmed.startsWith('#')) {
        continue;
      }
      const equalsIndex: number = trimmed.indexOf('=');
      if (equalsIndex > 0) {
        userProperties.set(trimmed.slice(0, equalsIndex).trim(), trimmed.slice(equalsIndex + 1));
      }
    }

    // Walk staging lines, replacing values for keys the user supplied
    const appliedKeys: Set<string> = new Set<string>();
    const resultLines: string[] = [];
    for (const line of stagingContent.split('\n')) {
      const trimmed: string = line.trim();
      if (!trimmed || trimmed.startsWith('#')) {
        resultLines.push(line);
        continue;
      }
      const equalsIndex: number = trimmed.indexOf('=');
      if (equalsIndex > 0) {
        const key: string = trimmed.slice(0, equalsIndex).trim();
        if (userProperties.has(key)) {
          resultLines.push(`${key}=${userProperties.get(key)}`);
          appliedKeys.add(key);
        } else {
          resultLines.push(line);
        }
      } else {
        resultLines.push(line);
      }
    }

    // Append keys from user's file that were not present in Solo's default
    for (const [key, value] of userProperties) {
      if (!appliedKeys.has(key)) {
        resultLines.push(`${key}=${value}`);
      }
    }

    await writeFile(stagingPath, resultLines.join('\n'));
  }

  private async updateApplicationPropertiesForBlockNode(applicationPropertiesPath: string): Promise<void> {
    const blockNodes: BlockNodeStateSchema[] = this.remoteConfig.configuration.components.state.blockNodes;
    const hasDeployedBlockNodes: boolean = blockNodes.length > 0;
    if (!hasDeployedBlockNodes) {
      return;
    }

    const lines: string[] = await readFile(applicationPropertiesPath, 'utf8').then((fileText): string[] =>
      fileText.split('\n'),
    );

    const streamMode: string = constants.BLOCK_STREAM_STREAM_MODE;
    const writerMode: string = constants.BLOCK_STREAM_WRITER_MODE;

    let streamModeUpdated: boolean = false;
    let writerModeUpdated: boolean = false;
    for (const line of lines) {
      if (line.startsWith('blockStream.streamMode=')) {
        lines[lines.indexOf(line)] = `blockStream.streamMode=${streamMode}`;
        streamModeUpdated = true;
        continue;
      }
      if (line.startsWith('blockStream.writerMode=')) {
        lines[lines.indexOf(line)] = `blockStream.writerMode=${writerMode}`;
        writerModeUpdated = true;
      }
    }

    if (!streamModeUpdated) {
      lines.push(`blockStream.streamMode=${streamMode}`);
    }
    if (!writerModeUpdated) {
      lines.push(`blockStream.writerMode=${writerMode}`);
    }

    await writeFile(applicationPropertiesPath, lines.join('\n') + '\n');
  }

  private async updateApplicationPropertiesWithChainId(
    applicationPropertiesPath: string,
    chainId: string,
  ): Promise<void> {
    const fileText: string = await readFile(applicationPropertiesPath, 'utf8');
    const lines: string[] = fileText.split('\n');

    for (const line of lines) {
      if (line.startsWith('contracts.chainId=')) {
        lines[lines.indexOf(line)] = `contracts.chainId=${chainId}`;
      }
    }

    await writeFile(applicationPropertiesPath, lines.join('\n') + '\n');
  }

  private async updateBoostrapPropertiesWithChainId(bootstrapPropertiesPath: string, chainId: string): Promise<void> {
    const fileText: string = await readFile(bootstrapPropertiesPath, 'utf8');
    const lines: string[] = fileText.split('\n');

    for (const line of lines) {
      if (line.startsWith('contracts.chainId=')) {
        lines[lines.indexOf(line)] = `contracts.chainId=${chainId}`;
      }
    }

    await writeFile(bootstrapPropertiesPath, lines.join('\n') + '\n');
  }

  private async updateApplicationPropertiesWithRealmAndShard(
    applicationPropertiesPath: string,
    realm: Realm,
    shard: Shard,
  ): Promise<void> {
    const fileContents: string = await readFile(applicationPropertiesPath, 'utf8');
    const lines: string[] = fileContents.split('\n');

    let realmUpdated: boolean = false;
    let shardUpdated: boolean = false;
    for (const line of lines) {
      if (line.startsWith('hedera.realm=')) {
        lines[lines.indexOf(line)] = `hedera.realm=${realm}`;
        realmUpdated = true;
        continue;
      }
      if (line.startsWith('hedera.shard=')) {
        lines[lines.indexOf(line)] = `hedera.shard=${shard}`;
        shardUpdated = true;
      }
    }

    if (!realmUpdated) {
      lines.push(`hedera.realm=${realm}`);
    }
    if (!shardUpdated) {
      lines.push(`hedera.shard=${shard}`);
    }

    let releaseTag: SemanticVersion<string> = new SemanticVersion<string>(versions.HEDERA_PLATFORM_VERSION);
    try {
      releaseTag = this.remoteConfig.configuration.versions.consensusNode;
    } catch {
      // Guard
    }

    let tssEnabled: boolean = false;
    try {
      tssEnabled = this.remoteConfig.configuration.state.tssEnabled;
    } catch {
      // Guard
    }

    if (!releaseTag.lessThan(versions.MINIMUM_HIERO_PLATFORM_VERSION_FOR_TSS) && tssEnabled) {
      lines.push('tss.hintsEnabled=true', 'tss.historyEnabled=true', 'tss.forceMockSignatures=false');

      if (this.remoteConfig.configuration.state.wrapsEnabled) {
        lines.push('tss.wrapsEnabled=true');
      }
    }

    await writeFile(applicationPropertiesPath, lines.join('\n') + '\n');
  }

  public async prepareValuesForNodeTransaction(
    applicationPropertiesPath: string,
    configTxtPath?: string,
  ): Promise<string> {
    const yamlRoot: AnyObject = {};
    if (configTxtPath) {
      this._setFileContentsAsValue('hedera.configMaps.configTxt', configTxtPath, yamlRoot);
    }
    await this.bumpHederaConfigVersion(applicationPropertiesPath);
    this._setFileContentsAsValue('hedera.configMaps.applicationProperties', applicationPropertiesPath, yamlRoot);

    const cachedValuesFile: string = PathEx.join(this.cacheDir, 'solo-node-transaction.yaml');
    return this.writeToYaml(cachedValuesFile, yamlRoot);
  }

  /**
   * Writes the YAML to file.
   *
   * @param cachedValuesFile - the target file to write the YAML root to.
   * @param yamlRoot - object to turn into YAML and write to file.
   */
  public async writeToYaml(cachedValuesFile: Path, yamlRoot: AnyObject): Promise<string> {
    return await new Promise<string>((resolve, reject): void => {
      fs.writeFile(cachedValuesFile, yaml.stringify(yamlRoot), (error): void => {
        if (error) {
          reject(error);
        }

        resolve(cachedValuesFile);
      });
    });
  }

  /**
   * Writes the contents of a file as a value for the given nested item path in the YAML object
   * @param itemPath - nested item path in the YAML object to store the file contents
   * @param valueFilePath - path to the file whose contents will be stored in the YAML object
   * @param yamlRoot - root of the YAML object
   */
  private _setFileContentsAsValue(itemPath: string, valueFilePath: string, yamlRoot: AnyObject): void {
    const fileContents: string = fs.readFileSync(valueFilePath, 'utf8');
    this._setValue(itemPath, fileContents, yamlRoot);
  }

  /**
   * Extracts gossip endpoints from saved state (network.json) if it exists
   * @param consensusNode - the consensus node to check
   * @param nodeSeq - the node sequence number (index in roster)
   * @returns the saved endpoint address or undefined if no saved state exists or IP is no longer valid
   * @private
   */
  private async extractSavedEndpoint(consensusNode: ConsensusNode, nodeSeq: number): Promise<Address | undefined> {
    try {
      const k8: K8 = this.k8Factory.getK8(consensusNode.context);
      const networkJsonPath: string = `${constants.HEDERA_HAPI_PATH}/output/network.json`;

      // Check if network.json exists in the pod
      const pods: Pod[] = await k8
        .pods()
        .list(NamespaceName.of(consensusNode.namespace), [`app=network-${consensusNode.name}`]);
      if (pods.length === 0) {
        return undefined;
      }

      const pod: Pod = pods[0];
      const podReference: PodReference = pod.podReference;

      // Get container reference
      const containerReference: ContainerReference = ContainerReference.of(podReference, constants.ROOT_CONTAINER);
      const container: Container = k8.containers().readByRef(containerReference);

      // Try to read network.json from the pod
      const networkJsonContent: string = await container.execContainer(['cat', networkJsonPath]);

      if (!networkJsonContent || networkJsonContent.includes('No such file')) {
        return undefined;
      }

      const networkJson: Record<string, unknown> = JSON.parse(networkJsonContent);
      const nodeMetadata: unknown = networkJson?.nodeMetadata?.[nodeSeq];
      const rosterEntry: {gossipEndpoint?: Array<Record<string, unknown>>} | undefined = (
        nodeMetadata as {rosterEntry?: {gossipEndpoint?: Array<Record<string, unknown>>}} | undefined
      )?.rosterEntry;
      const gossipEndpointRaw: Record<string, unknown> | undefined = rosterEntry?.gossipEndpoint?.[0];
      const port: number = (gossipEndpointRaw?.port as number) || 0;
      const domainName: string | undefined =
        typeof gossipEndpointRaw?.domainName === 'string' ? gossipEndpointRaw.domainName : undefined;
      const ipAddressV4: string | undefined =
        typeof gossipEndpointRaw?.ipAddressV4 === 'string' ? gossipEndpointRaw.ipAddressV4 : undefined;

      if (!gossipEndpointRaw) {
        return undefined;
      }

      // Check if endpoint uses domain name (FQDN)
      if (domainName) {
        this.logger.info(`Found saved endpoint for ${consensusNode.name}: ${domainName}:${port} (FQDN)`);
        return new Address(port, domainName);
      }

      // Check if endpoint uses IP address
      if (ipAddressV4) {
        // Decode base64 IP address
        const base64Ip: string = ipAddressV4 as string;
        const ipBytes: Buffer = Buffer.from(base64Ip, 'base64');
        const ipAddress: string = [...ipBytes].join('.');

        // Validate the saved IP still belongs to this node service.
        const serviceName: string = `network-${consensusNode.name}-svc`;
        const service: {spec?: {clusterIP?: string}} | undefined = await k8
          .services()
          .read(NamespaceName.of(consensusNode.namespace), serviceName);
        const serviceIpAddress: string | undefined = service?.spec?.clusterIP;
        if (serviceIpAddress !== ipAddress) {
          this.logger.warn(
            `Saved endpoint ${ipAddress}:${port} for ${consensusNode.name} does not match current ${serviceName} ClusterIP ${serviceIpAddress ?? 'undefined'}, falling back to current service address`,
          );
          return undefined;
        }

        this.logger.info(`Found saved endpoint for ${consensusNode.name}: ${ipAddress}:${port} (IP)`);
        return new Address(port, ipAddress);
      }

      return undefined;
    } catch (error: Error | unknown) {
      // If anything fails, return undefined to fall back to getExternalAddress
      this.logger.debug(
        `Could not extract saved endpoint for ${consensusNode.name}: ${error instanceof Error ? error.message : 'unknown error'}`,
      );
      return undefined;
    }
  }

  /**
   * Prepares config.txt file for the node
   * @param nodeAccountMap - the map of node aliases to account IDs
   * @param consensusNodes - the list of consensus nodes
   * @param destinationPath
   * @param releaseTagOverride - release tag override
   * @param [appName] - the app name (default: HederaNode.jar)
   * @param [chainId] - chain ID (298 for local network)
   * @returns the config.txt file path
   */
  public async prepareConfigTxt(
    nodeAccountMap: Map<NodeAlias, string>,
    consensusNodes: ConsensusNode[],
    destinationPath: string,
    releaseTagOverride: string,
    appName: string = constants.HEDERA_APP_NAME,
    chainId: string = constants.HEDERA_CHAIN_ID,
    gossipFqdnRestricted: boolean = true,
  ): Promise<string> {
    let releaseTag: string = releaseTagOverride;
    if (!nodeAccountMap || nodeAccountMap.size === 0) {
      throw new MissingArgumentError('nodeAccountMap the map of node IDs to account IDs is required');
    }

    if (!releaseTag) {
      releaseTag = versions.HEDERA_PLATFORM_VERSION;
    }

    if (!fs.existsSync(destinationPath)) {
      throw new IllegalArgumentError(`config destPath does not exist: ${destinationPath}`, destinationPath);
    }

    const configFilePath: string = PathEx.join(destinationPath, 'config.txt');
    if (fs.existsSync(configFilePath)) {
      fs.unlinkSync(configFilePath);
    }

    // init variables
    const internalPort: number = +constants.HEDERA_NODE_INTERNAL_GOSSIP_PORT;
    const externalPort: number = +constants.HEDERA_NODE_EXTERNAL_GOSSIP_PORT;
    const nodeStakeAmount: number = constants.HEDERA_NODE_DEFAULT_STAKE_AMOUNT;

    const releaseVersion: SemanticVersion<string> = new SemanticVersion(releaseTag);

    try {
      const configLines: string[] = [`swirld, ${chainId}`, `app, ${appName}`];

      let nodeSeq: number = 0;
      for (const consensusNode of consensusNodes) {
        const internalIP: string = helpers.getInternalAddress(
          releaseVersion,
          NamespaceName.of(consensusNode.namespace),
          consensusNode.name as NodeAlias,
        );

        // First try to extract endpoint from saved state (migration scenario)
        let address: Address | undefined = await this.extractSavedEndpoint(consensusNode, nodeSeq);

        // If no saved state, get current external address
        if (!address) {
          address = await Address.getExternalAddress(
            consensusNode,
            this.k8Factory.getK8(consensusNode.context),
            externalPort,
            gossipFqdnRestricted,
          );
        }

        const account: string | undefined = nodeAccountMap.get(consensusNode.name as NodeAlias);

        configLines.push(
          `address, ${nodeSeq}, ${nodeSeq}, ${consensusNode.name}, ${nodeStakeAmount}, ${internalIP}, ${internalPort}, ${address.hostString()}, ${address.port}, ${account}`,
        );

        nodeSeq += 1;
      }

      // TODO: remove once we no longer need less than v0.56
      if (releaseVersion.minor >= 41 && releaseVersion.minor < 56) {
        configLines.push(`nextNodeId, ${nodeSeq}`);
      }

      fs.writeFileSync(configFilePath, configLines.join('\n'));
      return configFilePath;
    } catch (error: Error | unknown) {
      throw new SoloError(
        `failed to generate config.txt, ${error instanceof Error ? (error as Error).message : 'unknown error'}`,
        error,
      );
    }
  }

  private parseGossipFqdnRestricted(applicationPropertiesText: string): boolean | undefined {
    const match: RegExpMatchArray | null = applicationPropertiesText.match(
      /^\s*nodes\.gossipFqdnRestricted\s*=\s*(true|false)\s*$/m,
    );
    if (match?.[1]) {
      return match[1].toLowerCase() === 'true';
    }
    return undefined;
  }

  private async getGossipFqdnRestricted(
    consensusNodes: ConsensusNode[],
    applicationPropertiesPath: string,
  ): Promise<boolean> {
    const firstNode: ConsensusNode | undefined = consensusNodes[0];
    if (firstNode) {
      try {
        const k8: K8 = this.k8Factory.getK8(firstNode.context);
        const configMap: ConfigMap = await k8
          .configMaps()
          .read(NamespaceName.of(firstNode.namespace), constants.NETWORK_NODE_SHARED_DATA_CONFIG_MAP_NAME);
        const configMapProperties: string | undefined = configMap.data?.[constants.APPLICATION_PROPERTIES];
        if (configMapProperties) {
          const parsedFromConfigMap: boolean | undefined = this.parseGossipFqdnRestricted(configMapProperties);
          if (parsedFromConfigMap !== undefined) {
            return parsedFromConfigMap;
          }
        }
      } catch {
        // Fall through to local application.properties
      }
    }

    if (fs.existsSync(applicationPropertiesPath)) {
      const applicationPropertiesContent: string = fs.readFileSync(applicationPropertiesPath, 'utf8');
      const parsedFromApplicationProperties: boolean | undefined =
        this.parseGossipFqdnRestricted(applicationPropertiesContent);
      if (parsedFromApplicationProperties !== undefined) {
        return parsedFromApplicationProperties;
      }
    }

    return true;
  }
}
