/**
 * @module Client
 */

import { constants, publicEncrypt } from "crypto";

import bs58 from "bs58";

import {
  CreateAccountOptions,
  ExportAccountOptions,
  GetAccountOptions,
  GetOrCreateAccountOptions,
  ImportAccountOptions,
  ListAccountsOptions,
  ListAccountsResult,
  ListTokenBalancesOptions,
  ListTokenBalancesResult,
  RequestFaucetOptions,
  SendTransactionOptions,
  SignatureResult,
  SignMessageOptions,
  SignTransactionOptions,
  SolanaClientInterface,
  UpdateSolanaAccountOptions,
} from "./solana.types.js";
import { toSolanaAccount } from "../../accounts/solana/toSolanaAccount.js";
import { SolanaAccount } from "../../accounts/solana/types.js";
import { requestFaucet } from "../../actions/solana/requestFaucet.js";
import {
  sendTransaction,
  type SendTransactionResult,
} from "../../actions/solana/sendTransaction.js";
import { signMessage } from "../../actions/solana/signMessage.js";
import {
  signTransaction,
  type SignTransactionResult,
} from "../../actions/solana/signTransaction.js";
import { Analytics } from "../../analytics.js";
import { ImportAccountPublicRSAKey } from "../../constants.js";
import { UserInputValidationError } from "../../errors.js";
import { APIError } from "../../openapi-client/errors.js";
import { CdpOpenApiClient } from "../../openapi-client/index.js";
import {
  decryptWithPrivateKey,
  formatSolanaPrivateKey,
  generateExportEncryptionKeyPair,
} from "../../utils/export.js";

/**
 * The namespace containing all Solana methods.
 */
export class SolanaClient implements SolanaClientInterface {
  /**
   * Creates a new Solana account.
   *
   * @param {CreateAccountOptions} options - Parameters for creating the Solana account.
   * @param {string} [options.name] - The name of the account.
   * @param {string} [options.idempotencyKey] - An idempotency key.
   *
   * @returns A promise that resolves to the newly created account.
   *
   * @example **Without arguments**
   *          ```ts
   *          const account = await cdp.solana.createAccount();
   *          ```
   *
   * @example **With a name**
   *          ```ts
   *          const account = await cdp.solana.createAccount({ name: "MyAccount" });
   *          ```
   *
   * @example **With an idempotency key**
   *          ```ts
   *          const idempotencyKey = uuidv4();
   *
   *          // First call
   *          await cdp.solana.createAccount({ idempotencyKey });
   *
   *          // Second call with the same idempotency key will return the same account
   *          await cdp.solana.createAccount({ idempotencyKey });
   *          ```
   */
  async createAccount(options: CreateAccountOptions = {}): Promise<SolanaAccount> {
    Analytics.trackAction({
      action: "create_account",
      accountType: "solana",
    });

    try {
      return this._createAccountInternal(options);
    } catch (error) {
      Analytics.trackError(error, "createAccount");
      throw error;
    }
  }

  /**
   * Exports a CDP Solana account's private key.
   * It is important to store the private key in a secure place after it's exported.
   *
   * @param {ExportAccountOptions} options - Parameters for exporting the Solana account.
   * @param {string} [options.address] - The address of the account.
   * @param {string} [options.name] - The name of the account.
   *
   * @returns A promise that resolves to the exported account's full 64-byte private key as a base58 encoded string.
   *
   * @example **With an address**
   * ```ts
   * const privateKey = await cdp.solana.exportAccount({
   *   address: "1234567890123456789012345678901234567890",
   * });
   * ```
   *
   * @example **With a name**
   * ```ts
   * const privateKey = await cdp.solana.exportAccount({
   *   name: "MyAccount",
   * });
   * ```
   */
  async exportAccount(options: ExportAccountOptions): Promise<string> {
    Analytics.trackAction({
      action: "export_account",
      accountType: "solana",
    });

    try {
      const { publicKey, privateKey } = await generateExportEncryptionKeyPair();

      const { encryptedPrivateKey } = await (async () => {
        if (options.address) {
          return CdpOpenApiClient.exportSolanaAccount(
            options.address,
            {
              exportEncryptionKey: publicKey,
            },
            options.idempotencyKey,
          );
        }

        if (options.name) {
          return CdpOpenApiClient.exportSolanaAccountByName(
            options.name,
            {
              exportEncryptionKey: publicKey,
            },
            options.idempotencyKey,
          );
        }

        throw new UserInputValidationError("Either address or name must be provided");
      })();

      const decryptedPrivateKey = decryptWithPrivateKey(privateKey, encryptedPrivateKey);
      return await formatSolanaPrivateKey(decryptedPrivateKey);
    } catch (error) {
      if (!(error instanceof UserInputValidationError)) {
        Analytics.trackError(error, "exportAccount");
      }
      throw error;
    }
  }

  /**
   * Imports a Solana account using a private key.
   * The private key will be encrypted before being stored securely.
   *
   * @param {ImportAccountOptions} options - Parameters for importing the Solana account.
   * @param {string} options.privateKey - The private key to import (32 or 64 bytes). Can be a base58 encoded string or raw bytes.
   * @param {string} [options.name] - The name of the account.
   * @param {string} [options.encryptionPublicKey] - The RSA public key for encrypting the private key.
   * @param {string} [options.idempotencyKey] - An idempotency key.
   *
   * @returns A promise that resolves to the imported account.
   *
   * @example **Import with private key only**
   *          ```ts
   *          const account = await cdp.solana.importAccount({
   *            privateKey: "3Kzj...",
   *          });
   *          ```
   *
   * @example **Import with name**
   *          ```ts
   *          const account = await cdp.solana.importAccount({
   *            privateKey: "3Kzj...",
   *            name: "ImportedAccount",
   *          });
   *          ```
   *
   * @example **Import with idempotency key**
   *          ```ts
   *          const idempotencyKey = uuidv4();
   *
   *          const account = await cdp.solana.importAccount({
   *            privateKey: "3Kzj...",
   *            name: "ImportedAccount",
   *            idempotencyKey,
   *          });
   *          ```
   */
  async importAccount(options: ImportAccountOptions): Promise<SolanaAccount> {
    Analytics.trackAction({
      action: "import_account",
      accountType: "solana",
    });

    try {
      let privateKeyBytes: Uint8Array = new Uint8Array();

      if (typeof options.privateKey === "string") {
        privateKeyBytes = bs58.decode(options.privateKey);
      } else {
        privateKeyBytes = options.privateKey;
      }

      if (privateKeyBytes.length !== 32 && privateKeyBytes.length !== 64) {
        throw new UserInputValidationError("Invalid private key length");
      }

      if (privateKeyBytes.length === 64) {
        privateKeyBytes = privateKeyBytes.subarray(0, 32);
      }

      const encryptionPublicKey = options.encryptionPublicKey || ImportAccountPublicRSAKey;

      const encryptedPrivateKey = publicEncrypt(
        {
          key: encryptionPublicKey,
          padding: constants.RSA_PKCS1_OAEP_PADDING,
          oaepHash: "sha256",
        },
        privateKeyBytes,
      );

      const openApiAccount = await CdpOpenApiClient.importSolanaAccount(
        {
          encryptedPrivateKey: encryptedPrivateKey.toString("base64"),
          name: options.name,
        },
        options.idempotencyKey,
      );

      const account = toSolanaAccount(CdpOpenApiClient, {
        account: openApiAccount,
      });

      return account;
    } catch (error) {
      if (!(error instanceof UserInputValidationError)) {
        Analytics.trackError(error, "importAccount");
      }
      throw error;
    }
  }

  /**
   * Gets a Solana account by its address.
   *
   * @param {GetAccountOptions} options - Parameters for getting the Solana account.
   * Either `address` or `name` must be provided.
   * If both are provided, lookup will be done by `address` and `name` will be ignored.
   * @param {string} [options.address] - The address of the account.
   * @param {string} [options.name] - The name of the account.
   *
   * @returns A promise that resolves to the account.
   *
   * @example **Get an account by address**
   *          ```ts
   *          const account = await cdp.solana.getAccount({
   *            address: "1234567890123456789012345678901234567890",
   *          });
   *          ```
   *
   * @example **Get an account by name**
   *          ```ts
   *          const account = await cdp.solana.getAccount({
   *            name: "MyAccount",
   *          });
   *          ```
   */
  async getAccount(options: GetAccountOptions): Promise<SolanaAccount> {
    Analytics.trackAction({
      action: "get_account",
      accountType: "solana",
    });

    try {
      return this._getAccountInternal(options);
    } catch (error) {
      Analytics.trackError(error, "getAccount");
      throw error;
    }
  }

  /**
   * Gets a Solana account by its address.
   *
   * @param {GetOrCreateAccountOptions} options - Parameters for getting or creating the Solana account.
   * @param {string} options.name - The name of the account.
   *
   * @returns A promise that resolves to the account.
   *
   * @example
   * ```ts
   * const account = await cdp.solana.getOrCreateAccount({
   *   name: "MyAccount",
   * });
   * ```
   */
  async getOrCreateAccount(options: GetOrCreateAccountOptions): Promise<SolanaAccount> {
    Analytics.trackAction({
      action: "get_or_create_account",
      accountType: "solana",
    });

    try {
      try {
        const account = await this._getAccountInternal(options);
        return account;
      } catch (error) {
        // If it failed because the account doesn't exist, create it
        const doesAccountNotExist = error instanceof APIError && error.statusCode === 404;
        if (doesAccountNotExist) {
          try {
            const account = await this._createAccountInternal(options);
            return account;
          } catch (error) {
            // If it failed because the account already exists, get the existing account
            const doesAccountAlreadyExist = error instanceof APIError && error.statusCode === 409;
            if (doesAccountAlreadyExist) {
              const account = await this._getAccountInternal(options);
              return account;
            }
            throw error;
          }
        }

        throw error;
      }
    } catch (error) {
      Analytics.trackError(error, "getOrCreateAccount");
      throw error;
    }
  }

  /**
   * Lists all Solana accounts.
   *
   * @param {ListAccountsOptions} options - Parameters for listing the Solana accounts.
   * @param {number} [options.pageSize] - The number of accounts to return.
   * @param {string} [options.pageToken] - The page token to begin listing from.
   * This is obtained by previous calls to this method.
   *
   * @returns A promise that resolves to an array of Solana account instances.
   *
   * @example **Without arguments**
   *          ```ts
   *          const accounts = await cdp.solana.listAccounts();
   *          ```
   *
   * @example **With pagination**
   *          ```ts
   *          let page = await cdp.solana.listAccounts();
   *
   *          while (page.nextPageToken) {
   *            page = await cdp.solana.listAccounts({ pageToken: page.nextPageToken });
   *          }
   *
   *          page.accounts.forEach(account => console.log(account));
   *          ```
   * }
   * ```
   */
  async listAccounts(options: ListAccountsOptions = {}): Promise<ListAccountsResult> {
    Analytics.trackAction({
      action: "list_accounts",
      accountType: "solana",
    });

    try {
      const solAccounts = await CdpOpenApiClient.listSolanaAccounts({
        pageSize: options.pageSize,
        pageToken: options.pageToken,
      });

      return {
        accounts: solAccounts.accounts.map(account => {
          const solanaAccount = toSolanaAccount(CdpOpenApiClient, {
            account,
          });

          return solanaAccount;
        }),
        nextPageToken: solAccounts.nextPageToken,
      };
    } catch (error) {
      Analytics.trackError(error, "listAccounts");
      throw error;
    }
  }

  /**
   * Requests funds from a Solana faucet.
   *
   * @param {RequestFaucetOptions} options - Parameters for requesting funds from the Solana faucet.
   * @param {string} options.address - The address to request funds for.
   * @param {string} options.token - The token to request funds for.
   * @param {string} [options.idempotencyKey] - An idempotency key.
   *
   * @returns A promise that resolves to the transaction signature.
   *
   * @example
   *          ```ts
   *          const signature = await cdp.solana.requestFaucet({
   *            address: "1234567890123456789012345678901234567890",
   *            token: "sol",
   *          });
   *          ```
   */
  async requestFaucet(options: RequestFaucetOptions): Promise<SignatureResult> {
    Analytics.trackAction({
      action: "request_faucet",
      accountType: "solana",
    });

    try {
      return requestFaucet(CdpOpenApiClient, options);
    } catch (error) {
      Analytics.trackError(error, "requestFaucet");
      throw error;
    }
  }

  /**
   * Signs a message.
   *
   * @param {SignMessageOptions} options - Parameters for signing the message.
   * @param {string} options.address - The address to sign the message for.
   * @param {string} options.message - The message to sign.
   * @param {string} [options.idempotencyKey] - An idempotency key.
   *
   * @returns A promise that resolves to the signature.
   *
   * @example
   * ```ts
   * // Create a Solana account
   * const account = await cdp.solana.createAccount();
   *
   * // When you want to sign a message, you can do so by address
   * const signature = await cdp.solana.signMessage({
   *   address: account.address,
   *   message: "Hello, world!",
   * });
   * ```
   */
  async signMessage(options: SignMessageOptions): Promise<SignatureResult> {
    Analytics.trackAction({
      action: "sign_message",
      accountType: "solana",
    });

    try {
      return signMessage(CdpOpenApiClient, options);
    } catch (error) {
      Analytics.trackError(error, "signMessage");
      throw error;
    }
  }

  /**
   * Signs a transaction.
   *
   * @param {SignTransactionOptions} options - Parameters for signing the transaction.
   * @param {string} options.address - The address to sign the transaction for.
   * @param {string} options.transaction - The transaction to sign.
   * @param {string} [options.idempotencyKey] - An idempotency key.
   *
   * @returns A promise that resolves to the signature.
   *
   * @example
   * ```ts
   * // Create a Solana account
   * const account = await cdp.solana.createAccount();
   *
   * // Build your transaction using @solana/kit
   * import {
   *   address as solanaAddress,
   *   appendTransactionMessageInstructions,
   *   compileTransaction,
   *   createNoopSigner,
   *   createSolanaRpc,
   *   createTransactionMessage,
   *   getBase64EncodedWireTransaction,
   *   pipe,
   *   setTransactionMessageFeePayer,
   *   setTransactionMessageLifetimeUsingBlockhash,
   * } from "@solana/kit";
   * import { getTransferSolInstruction } from "@solana-program/system";
   *
   * const rpc = createSolanaRpc("https://api.devnet.solana.com");
   * const { value: { blockhash, lastValidBlockHeight } } = await rpc.getLatestBlockhash().send();
   *
   * const txMsg = pipe(
   *   createTransactionMessage({ version: 0 }),
   *   (tx) => setTransactionMessageFeePayer(solanaAddress(account.address), tx),
   *   (tx) => setTransactionMessageLifetimeUsingBlockhash(
   *     { blockhash, lastValidBlockHeight },
   *     tx,
   *   ),
   *   (tx) => appendTransactionMessageInstructions([
   *     getTransferSolInstruction({
   *       source: createNoopSigner(solanaAddress(account.address)),
   *       destination: solanaAddress("3KzDtddx4i53FBkvCzuDmRbaMozTZoJBb1TToWhz3JfE"),
   *       amount: 10000n,
   *     }),
   *   ], tx),
   * );
   *
   * // Base64 encode the compiled transaction
   * const transaction = getBase64EncodedWireTransaction(compileTransaction(txMsg));
   *
   * // Sign the transaction via the CDP API
   * const signature = await cdp.solana.signTransaction({
   *   address: account.address,
   *   transaction,
   * });
   * ```
   */
  async signTransaction(options: SignTransactionOptions): Promise<SignTransactionResult> {
    Analytics.trackAction({
      action: "sign_transaction",
      accountType: "solana",
    });

    try {
      return signTransaction(CdpOpenApiClient, options);
    } catch (error) {
      Analytics.trackError(error, "signTransaction");
      throw error;
    }
  }

  /**
   * Updates a CDP Solana account.
   *
   * @param {UpdateSolanaAccountOptions} [options] - Optional parameters for creating the account.
   * @param {string} options.address - The address of the account to update
   * @param {UpdateSolanaAccountBody} options.update - An object containing account fields to update.
   * @param {string} [options.update.name] - The new name for the account.
   * @param {string} [options.update.accountPolicy] - The ID of a Policy to apply to the account.
   * @param {string} [options.idempotencyKey] - An idempotency key.
   *
   * @returns A promise that resolves to the updated account.
   *
   * @example **With a name**
   *          ```ts
   *          const account = await cdp.sol.updateAccount({ address: "...", update: { name: "New Name" } });
   *          ```
   *
   * @example **With an account policy**
   *          ```ts
   *          const account = await cdp.sol.updateAccount({ address: "...", update: { accountPolicy: "73bcaeeb-d7af-4615-b064-42b5fe83a31e" } });
   *          ```
   *
   * @example **With an idempotency key**
   *          ```ts
   *          const idempotencyKey = uuidv4();
   *
   *          // First call
   *          await cdp.sol.updateAccount({
   *            address: "0x...",
   *            update: { accountPolicy: "73bcaeeb-d7af-4615-b064-42b5fe83a31e" },
   *            idempotencyKey,
   *          });
   *
   *          // Second call with the same idempotency key will not update
   *          await cdp.sol.updateAccount({
   *            address: '0x...',
   *            update: { name: "" },
   *            idempotencyKey,
   *          });
   *          ```
   */
  async updateAccount(options: UpdateSolanaAccountOptions): Promise<SolanaAccount> {
    Analytics.trackAction({
      action: "update_account",
      accountType: "solana",
    });

    try {
      const openApiAccount = await CdpOpenApiClient.updateSolanaAccount(
        options.address,
        options.update,
        options.idempotencyKey,
      );

      const account = toSolanaAccount(CdpOpenApiClient, {
        account: openApiAccount,
      });

      return account;
    } catch (error) {
      Analytics.trackError(error, "updateAccount");
      throw error;
    }
  }

  /**
   * Sends a Solana transaction using the Coinbase API.
   *
   * @param {SendTransactionOptions} options - Parameters for sending the Solana transaction.
   * @param {string} options.network - The network to send the transaction to.
   * @param {string} options.transaction - The base64 encoded transaction to send.
   * @param {boolean} [options.useCdpSponsor] - Whether CDP should sponsor the transaction fees.
   * @param {string} [options.idempotencyKey] - An idempotency key.
   *
   * @returns A promise that resolves to the transaction result.
   *
   * @example
   * ```ts
   * const signature = await cdp.solana.sendTransaction({
   *   network: "solana-devnet",
   *   transaction: "...",
   *   useCdpSponsor: true,
   * });
   * ```
   */
  async sendTransaction(options: SendTransactionOptions): Promise<SendTransactionResult> {
    Analytics.trackAction({
      action: "send_transaction",
      accountType: "solana",
      properties: {
        network: options.network,
      },
    });

    try {
      return sendTransaction(CdpOpenApiClient, options);
    } catch (error) {
      Analytics.trackError(error, "sendTransaction");
      throw error;
    }
  }

  /**
   * Lists the token balances for a Solana account.
   *
   * @param {ListTokenBalancesOptions} options - Parameters for listing the Solana token balances.
   * @param {string} options.address - The address of the account to list token balances for.
   * @param {string} [options.network] - The network to list token balances for. Defaults to "solana".
   * @param {number} [options.pageSize] - The number of token balances to return.
   * @param {string} [options.pageToken] - The page token to begin listing from.
   * This is obtained by previous calls to this method.
   *
   * @returns A promise that resolves to an array of Solana token balance instances.
   *
   * @example
   * ```ts
   * const balances = await cdp.solana.listTokenBalances({ address: "...", network: "solana-devnet" });
   * ```
   */
  async listTokenBalances(options: ListTokenBalancesOptions): Promise<ListTokenBalancesResult> {
    Analytics.trackAction({
      action: "list_token_balances",
      accountType: "solana",
      properties: {
        network: options.network,
      },
    });

    try {
      const tokenBalances = await CdpOpenApiClient.listSolanaTokenBalances(
        options.network || "solana",
        options.address,
        {
          pageSize: options.pageSize,
          pageToken: options.pageToken,
        },
      );

      return {
        balances: tokenBalances.balances.map(balance => {
          return {
            amount: {
              amount: BigInt(balance.amount.amount),
              decimals: balance.amount.decimals,
            },
            token: {
              mintAddress: balance.token.mintAddress,
              name: balance.token.name,
              symbol: balance.token.symbol,
            },
          };
        }),
        nextPageToken: tokenBalances.nextPageToken,
      };
    } catch (error) {
      Analytics.trackError(error, "listTokenBalances");
      throw error;
    }
  }

  /**
   * Internal method to create a Solana account without tracking analytics.
   * Used internally by composite operations to avoid double-counting.
   *
   * @param {CreateAccountOptions} options - Parameters for creating the account.
   * @returns {Promise<SolanaAccount>} A promise that resolves to the newly created account.
   */
  private async _createAccountInternal(options: CreateAccountOptions = {}): Promise<SolanaAccount> {
    const openApiAccount = await CdpOpenApiClient.createSolanaAccount(
      {
        name: options.name,
        accountPolicy: options.accountPolicy,
      },
      options.idempotencyKey,
    );

    const account = toSolanaAccount(CdpOpenApiClient, {
      account: openApiAccount,
    });

    return account;
  }

  /**
   * Internal method to get a Solana account without tracking analytics.
   * Used internally by composite operations to avoid double-counting.
   *
   * @param {GetAccountOptions} options - Parameters for getting the account.
   * @returns {Promise<SolanaAccount>} A promise that resolves to the account.
   */
  private async _getAccountInternal(options: GetAccountOptions): Promise<SolanaAccount> {
    const openApiAccount = await (() => {
      if (options.address) {
        return CdpOpenApiClient.getSolanaAccount(options.address);
      }

      if (options.name) {
        return CdpOpenApiClient.getSolanaAccountByName(options.name);
      }

      throw new UserInputValidationError("Either address or name must be provided");
    })();

    const account = toSolanaAccount(CdpOpenApiClient, {
      account: openApiAccount,
    });

    return account;
  }
}
