// SPDX-License-Identifier: Apache-2.0

import * as fs from 'node:fs';
import {Listr} from 'listr2';
import * as path from 'node:path';
import {IllegalArgumentError} from './errors/illegal-argument-error.js';
import {MissingArgumentError} from './errors/missing-argument-error.js';
import {SoloError} from './errors/solo-error.js';
import * as constants from './constants.js';
import {type ConfigManager} from './config-manager.js';
import {type K8Factory} from '../integration/kube/k8-factory.js';
import {Templates} from './templates.js';
import {Flags as flags} from '../commands/flags.js';
import * as Base64 from 'js-base64';
import chalk from 'chalk';

import {type SoloLogger} from './logging/solo-logger.js';
import {type NodeAlias} from '../types/aliases.js';
import {inject, injectable} from 'tsyringe-neo';
import {patchInject} from './dependency-injection/container-helper.js';
import {NamespaceName} from '../types/namespace/namespace-name.js';
import {type PodReference} from '../integration/kube/resources/pod/pod-reference.js';
import {ContainerReference} from '../integration/kube/resources/container/container-reference.js';
import {type ContainerName} from '../integration/kube/resources/container/container-name.js';
import {SecretType} from '../integration/kube/resources/secret/secret-type.js';
import {InjectTokens} from './dependency-injection/inject-tokens.js';
import {type ConsensusNode} from './model/consensus-node.js';
import {PathEx} from '../business/utils/path-ex.js';
import {PackageDownloader} from './package-downloader.js';
import {Containers} from '../integration/kube/resources/container/containers.js';
import {Container} from '../integration/kube/resources/container/container.js';

/** PlatformInstaller install platform code in the root-container of a network pod */
@injectable()
export class PlatformInstaller {
  public constructor(
    @inject(InjectTokens.SoloLogger) private logger?: SoloLogger,
    @inject(InjectTokens.K8Factory) private k8Factory?: K8Factory,
    @inject(InjectTokens.ConfigManager) private configManager?: ConfigManager,
    @inject(InjectTokens.PackageDownloader) private packageDownloader?: PackageDownloader,
  ) {
    this.logger = patchInject(logger, InjectTokens.SoloLogger, this.constructor.name);
    this.k8Factory = patchInject(k8Factory, InjectTokens.K8Factory, this.constructor.name);
    this.configManager = patchInject(configManager, InjectTokens.ConfigManager, this.constructor.name);
    this.packageDownloader = patchInject(packageDownloader, InjectTokens.PackageDownloader, this.constructor.name);
  }

  private _getNamespace(): NamespaceName {
    const ns: NamespaceName = this.configManager.getFlag<NamespaceName>(flags.namespace);
    if (!ns) {
      throw new MissingArgumentError('namespace is not set');
    }
    return ns;
  }

  public validatePlatformReleaseDir(releaseDirectory: string): void {
    if (!releaseDirectory) {
      throw new MissingArgumentError('releaseDirectory is required');
    }
    if (!fs.existsSync(releaseDirectory)) {
      throw new IllegalArgumentError('releaseDirectory does not exists', releaseDirectory);
    }

    const dataDirectory: string = `${releaseDirectory}/data`;
    const appsDirectory: string = `${releaseDirectory}/${constants.HEDERA_DATA_APPS_DIR}`;
    const libraryDirectory: string = `${releaseDirectory}/${constants.HEDERA_DATA_LIB_DIR}`;

    if (!fs.existsSync(dataDirectory)) {
      throw new IllegalArgumentError('releaseDirectory does not have data directory', releaseDirectory);
    }

    if (!fs.existsSync(appsDirectory)) {
      throw new IllegalArgumentError(
        `'${constants.HEDERA_DATA_APPS_DIR}' missing in '${releaseDirectory}'`,
        releaseDirectory,
      );
    }

    if (!fs.existsSync(libraryDirectory)) {
      throw new IllegalArgumentError(
        `'${constants.HEDERA_DATA_LIB_DIR}' missing in '${releaseDirectory}'`,
        releaseDirectory,
      );
    }

    const appsJarFiles: string[] = fs
      .readdirSync(appsDirectory)
      .filter((file: string): boolean => file.endsWith('.jar'));
    if (appsJarFiles.length === 0) {
      throw new IllegalArgumentError(
        `No jar files found in '${constants.HEDERA_DATA_APPS_DIR}' in releaseDir: ${releaseDirectory}`,
        releaseDirectory,
      );
    }

    const libraryJarFiles: string[] = fs
      .readdirSync(libraryDirectory)
      .filter((file: string): boolean => file.endsWith('.jar'));
    if (libraryJarFiles.length === 0) {
      throw new IllegalArgumentError(
        `No jar files found in '${constants.HEDERA_DATA_LIB_DIR}' in releaseDir: ${releaseDirectory}`,
        releaseDirectory,
      );
    }
  }

  public async getPlatformRelease(stagingDirectory: string, tag: string): Promise<string[]> {
    if (!tag) {
      throw new MissingArgumentError('tag is required');
    }

    // Download the platform zip client-side into {stagingDir}/build/
    const buildDirectory: string = PathEx.join(stagingDirectory ?? constants.SOLO_CACHE_DIR, 'build');
    if (!fs.existsSync(buildDirectory)) {
      fs.mkdirSync(buildDirectory, {recursive: true});
    }
    const zipPath: string = await this.packageDownloader.fetchPlatform(tag, buildDirectory);

    // Ensure the checksum file is also present (fetchPlatform returns early on cache hit without re-downloading it)
    const checksumPath: string = PathEx.join(buildDirectory, `build-${tag}.sha384`);
    if (!fs.existsSync(checksumPath)) {
      const releaseDirectory: string = Templates.prepareReleasePrefix(tag);
      const checksumURL: string = `${constants.HEDERA_BUILDS_URL}/node/software/${releaseDirectory}/build-${tag}.sha384`;
      await this.packageDownloader.fetchFile(checksumURL, checksumPath);
    }

    return [zipPath, checksumPath];
  }

  /** Fetch and extract platform code into the container */
  public async fetchPlatform(
    podReference: PodReference,
    tag: string,
    zipPath: string,
    checksumPath: string,
    context?: string,
  ) {
    if (!podReference) {
      throw new MissingArgumentError('podReference is required');
    }
    if (!tag) {
      throw new MissingArgumentError('tag is required');
    }
    if (!zipPath) {
      throw new IllegalArgumentError('zipPath is required');
    }
    if (!checksumPath) {
      throw new IllegalArgumentError('checksumPath is required');
    }

    try {
      // Upload zip and checksum to the container — extract-platform.sh expects them in HEDERA_USER_HOME_DIR
      await this.copyFiles(podReference, [zipPath, checksumPath], constants.HEDERA_USER_HOME_DIR, undefined, context);

      const scriptName: string = 'extract-platform.sh';
      const sourcePath: string = PathEx.joinWithRealPath(constants.RESOURCES_DIR, scriptName);
      await this.copyFiles(podReference, [sourcePath], constants.HEDERA_USER_HOME_DIR, undefined, context);

      const extractScript: string = `${constants.HEDERA_USER_HOME_DIR}/${scriptName}`; // inside the container
      const containerReference: ContainerReference = ContainerReference.of(podReference, constants.ROOT_CONTAINER);

      const k8Containers: Containers = this.k8Factory.getK8(context).containers();

      const container: Container = k8Containers.readByRef(containerReference);

      await container.execContainer('sync'); // ensure all writes are flushed before executing the script
      await container.execContainer(`chmod +x ${extractScript}`);
      await container.execContainer(`chown root:root ${extractScript}`);
      await container.execContainer([extractScript, tag]);

      return true;
    } catch (error) {
      const logFile: string = `${constants.HEDERA_HAPI_PATH}/output/extract-platform.log`;
      const response: string = await this.k8Factory
        .getK8(context)
        .containers()
        .readByRef(ContainerReference.of(podReference, constants.ROOT_CONTAINER))
        .execContainer(['bash', '-c', `cat ${logFile} || echo "Log file not found or empty"`]);
      this.logger.showUser(`Log file content from ${logFile}:\n${response}`);

      const message: string = `failed to extract platform code in this pod '${podReference}' while using the '${context}' context: ${error.message}`;
      throw new SoloError(message, error);
    }
  }

  /**
   * Copy a list of files to a directory in the container
   * @param podReference - pod reference
   * @param sourceFiles - list of source files
   * @param destinationDirectory - destination directory
   * @param [container] - name of the container
   * @param [context]
   * @returns a list of paths of the copied files insider the container
   */
  public async copyFiles(
    podReference: PodReference,
    sourceFiles: string[],
    destinationDirectory: string,
    container: ContainerName = constants.ROOT_CONTAINER,
    context?: string,
  ): Promise<string[]> {
    try {
      const containerReference: ContainerReference = ContainerReference.of(podReference, container);
      const copiedFiles: string[] = [];

      // prepare the file mapping
      for (const sourcePath of sourceFiles) {
        if (!fs.existsSync(sourcePath)) {
          throw new SoloError(`file does not exist: ${sourcePath}`);
        }

        const k8Containers: Containers = this.k8Factory.getK8(context).containers();

        if (!(await k8Containers.readByRef(containerReference).hasDir(destinationDirectory))) {
          await k8Containers.readByRef(containerReference).mkdir(destinationDirectory);
        }

        this.logger.debug(`Copying file into ${podReference.name}: ${sourcePath} -> ${destinationDirectory}`);
        await k8Containers.readByRef(containerReference).copyTo(sourcePath, destinationDirectory);

        const fileName: string = path.basename(sourcePath);
        copiedFiles.push(PathEx.join(destinationDirectory, fileName));
      }

      return copiedFiles;
    } catch (error) {
      throw new SoloError(
        `failed to copy files: ${sourceFiles.join(', ')}, to ${podReference.name}:${destinationDirectory}: ${error.message}`,
        error,
      );
    }
  }

  public async copyGossipKeys(
    consensusNode: ConsensusNode,
    stagingDirectory: string,
    consensusNodes: ConsensusNode[],
  ): Promise<void> {
    if (!consensusNode) {
      throw new MissingArgumentError('consensusNode is required');
    }
    if (!stagingDirectory) {
      throw new MissingArgumentError('stagingDirectory is required');
    }
    if (!consensusNodes || consensusNodes.length <= 0) {
      throw new MissingArgumentError('consensusNodes cannot be empty');
    }

    try {
      // copy private keys for the node
      const sourceFiles: string[] = [
        PathEx.joinWithRealPath(
          stagingDirectory,
          'keys',
          Templates.renderGossipPemPrivateKeyFile(consensusNode.name as NodeAlias),
        ),
      ];

      // copy all public keys for all nodes
      for (const consensusNode of consensusNodes) {
        sourceFiles.push(
          PathEx.joinWithRealPath(
            stagingDirectory,
            'keys',
            Templates.renderGossipPemPublicKeyFile(consensusNode.name as NodeAlias),
          ),
        );
      }

      const data: Record<string, string> = {};
      for (const sourceFile of sourceFiles) {
        const fileContents: Buffer = fs.readFileSync(sourceFile);
        const fileName: string = path.basename(sourceFile);
        // @ts-expect-error - Dynamic key assignment is intentional
        data[fileName] = Base64.encode(fileContents);
      }

      const secretCreated: boolean = await this.k8Factory
        .getK8(consensusNode.context)
        .secrets()
        .createOrReplace(
          NamespaceName.of(consensusNode.namespace),
          Templates.renderGossipKeySecretName(consensusNode.name as NodeAlias),
          SecretType.OPAQUE,
          data,
          Templates.renderGossipKeySecretLabelObject(consensusNode.name as NodeAlias),
        );

      if (!secretCreated) {
        throw new SoloError(`failed to create secret for gossip keys for node '${consensusNode.name}'`);
      }
    } catch (error) {
      const errorMessage: string = error instanceof Error ? error.message : String(error);
      const message: string = `failed to copy gossip keys to secret '${Templates.renderGossipKeySecretName(consensusNode.name as NodeAlias)}': ${errorMessage}`;
      throw new SoloError(message, error);
    }
  }

  public async copyTLSKeys(
    consensusNodes: ConsensusNode[],
    stagingDirectory: string,
    contexts: string[],
  ): Promise<void> {
    if (!consensusNodes || consensusNodes.length <= 0) {
      throw new MissingArgumentError('consensusNodes cannot be empty');
    }
    if (!stagingDirectory) {
      throw new MissingArgumentError('stagingDirectory is required');
    }

    try {
      const data: Record<string, string> = {};

      for (const consensusNode of consensusNodes) {
        const sourceFiles: string[] = [
          PathEx.joinWithRealPath(
            stagingDirectory,
            'keys',
            Templates.renderTLSPemPrivateKeyFile(consensusNode.name as NodeAlias),
          ),
          PathEx.joinWithRealPath(
            stagingDirectory,
            'keys',
            Templates.renderTLSPemPublicKeyFile(consensusNode.name as NodeAlias),
          ),
        ];

        for (const sourceFile of sourceFiles) {
          const fileContents: Buffer = fs.readFileSync(sourceFile);
          const fileName: string = path.basename(sourceFile);
          // @ts-expect-error - Dynamic key assignment is intentional
          data[fileName] = Base64.encode(fileContents);
        }
      }

      for (const context of contexts) {
        const secretCreated: boolean = await this.k8Factory
          .getK8(context)
          .secrets()
          .createOrReplace(this._getNamespace(), 'network-node-hapi-app-secrets', SecretType.OPAQUE, data);

        if (!secretCreated) {
          throw new SoloError('failed to create secret for TLS keys');
        }
      }
    } catch (error: unknown) {
      throw new SoloError('failed to copy TLS keys to secret', error);
    }
  }

  public async setPathPermission(
    podReference: PodReference,
    destinationPath: string,
    mode: string = '0755',
    recursive: boolean = true,
    container: ContainerName = constants.ROOT_CONTAINER,
    context?: string,
  ): Promise<boolean> {
    if (!podReference) {
      throw new MissingArgumentError('podReference is required');
    }
    if (!destinationPath) {
      throw new MissingArgumentError('destPath is required');
    }
    const containerReference: ContainerReference = ContainerReference.of(podReference, container);

    const recursiveFlag: string = recursive ? '-R' : '';

    const k8Containers: Containers = this.k8Factory.getK8(context).containers();

    await k8Containers
      .readByRef(containerReference)
      .execContainer(['bash', '-c', `chown ${recursiveFlag} hedera:hedera ${destinationPath} 2>/dev/null || true`]);
    await k8Containers
      .readByRef(containerReference)
      .execContainer(['bash', '-c', `chmod ${recursiveFlag} ${mode} ${destinationPath} 2>/dev/null || true`]);

    return true;
  }

  public async setPlatformDirPermissions(podReference: PodReference, context?: string): Promise<boolean> {
    if (!podReference) {
      throw new MissingArgumentError('podReference is required');
    }

    try {
      const destinationPaths: string[] = [constants.HEDERA_HAPI_PATH, constants.HEDERA_HGCAPP_DIR];

      for (const destinationPath of destinationPaths) {
        await this.setPathPermission(podReference, destinationPath, undefined, undefined, undefined, context);
      }

      return true;
    } catch (error) {
      throw new SoloError(`failed to set permission in '${podReference.name}'`, error);
    }
  }

  /** Return a list of task to perform node directory setup */
  public taskSetup(podReference: PodReference, stagingDirectory: string, isGenesis: boolean, context?: string): Listr {
    return new Listr(
      [
        {
          title: 'Copy configuration files',
          task: async (): Promise<void> =>
            await this.copyConfigurationFiles(stagingDirectory, podReference, isGenesis, context),
        },
        {
          title: 'Set file permissions',
          task: async (): Promise<boolean> => await this.setPlatformDirPermissions(podReference, context),
        },
      ],
      {
        concurrent: false,
        rendererOptions: {
          collapseSubtasks: false,
        },
      },
    );
  }

  /**
   * Copy configuration files to the network consensus node pod
   * @param stagingDirectory - staging directory path
   * @param podReference - pod reference
   * @param isGenesis - true if this is `solo consensus node setup` and we are at genesis
   * @param context
   */
  private async copyConfigurationFiles(
    stagingDirectory: string,
    podReference: PodReference,
    isGenesis: boolean,
    context?: string,
  ): Promise<void> {
    if (isGenesis) {
      const genesisNetworkJson: string[] = [PathEx.joinWithRealPath(stagingDirectory, 'genesis-network.json')];
      await this.copyFiles(
        podReference,
        genesisNetworkJson,
        `${constants.HEDERA_HAPI_PATH}/data/config`,
        undefined,
        context,
      );
    }

    // TODO: temporarily disable this until we add logic to only set this when the user provides the node override gossip endpoints for each node they want to override
    // const nodeOverridesYaml = [PathEx.joinWithRealPath(stagingDirectory, constants.NODE_OVERRIDE_FILE)];
    // await this.copyFiles(podReference, nodeOverridesYaml, `${constants.HEDERA_HAPI_PATH}/data/config`, undefined, context);
  }

  /**
   * Return a list of task to copy the node keys to the staging directory
   *
   * It assumes the staging directory has the following files and resources:
   * <li>${staging}/keys/s-public-<nodeAlias>.pem: private signing key for a node</li>
   * <li>${staging}/keys/s-private-<nodeAlias>.pem: public signing key for a node</li>
   * <li>${staging}/keys/a-public-<nodeAlias>.pem: private agreement key for a node</li>
   * <li>${staging}/keys/a-private-<nodeAlias>.pem: public agreement key for a node</li>
   * <li>${staging}/keys/hedera-<nodeAlias>.key: gRPC TLS key for a node</li>
   * <li>${staging}/keys/hedera-<nodeAlias>.crt: gRPC TLS cert for a node</li>
   *
   * @param stagingDirectory staging directory path
   * @param consensusNodes list of consensus nodes
   * @param contexts list of k8s contexts
   */
  public copyNodeKeys(stagingDirectory: string, consensusNodes: ConsensusNode[], contexts: string[]): any[] {
    const subTasks: any[] = [
      {
        title: 'Copy TLS keys',
        task: async (): Promise<void> => await this.copyTLSKeys(consensusNodes, stagingDirectory, contexts),
      },
    ];

    for (const consensusNode of consensusNodes) {
      subTasks.push({
        title: `Node: ${chalk.yellow(consensusNode.name)}, cluster: ${chalk.yellow(consensusNode.context)}`,
        task: () =>
          new Listr(
            [
              {
                title: 'Copy Gossip keys',
                task: async () => await this.copyGossipKeys(consensusNode, stagingDirectory, consensusNodes),
              },
            ],
            {
              concurrent: false,
              rendererOptions: {
                collapseSubtasks: false,
              },
            },
          ),
      });
    }
    return subTasks;
  }
}
