import {
  Connection,
  Keypair,
  PublicKey,
  SystemProgram,
  Transaction,
} from "@solana/web3.js";
import {
  ASSOCIATED_TOKEN_PROGRAM_ID,
  createAssociatedTokenAccountInstruction,
  createTransferCheckedInstruction,
  getMint,
  getAccount,
  getAssociatedTokenAddress,
  TOKEN_2022_PROGRAM_ID,
  TOKEN_PROGRAM_ID,
  TokenAccountNotFoundError,
  getAccountLenForMint,
} from "@solana/spl-token";
import { createTransferCheckedWithFeeInstruction } from "@solana/spl-token";
import { calculateEpochFee, getTransferFeeConfig } from "@solana/spl-token";
import {
  Contract,
  JsonRpcProvider,
  TransactionRequest,
  Wallet,
} from "ethers";
import { EVM_CHAIN_TYPES, normalizeRequiredChainType, parseChainStringMap } from "./ChainConfig";
import { IMappingStore } from "./StoreContracts";
import { WalletGenerationService } from "./WalletGenerationService";

interface SweepWithdrawalRequest {
  user_id?: number;
  destination_address?: string;
  amount?: string | number;
  contract_address?: string;
  sweep_all?: boolean;
  min_reserve?: string | number;
}

interface SweepResult {
  chain_type: string;
  user_id: number;
  source_address: string;
  destination_address: string;
  asset: string;
  amount: string;
  tx_hash: string;
}

const ERC20_ABI = [
  "function balanceOf(address owner) view returns (uint256)",
  "function decimals() view returns (uint8)",
  "function transfer(address to, uint256 amount) returns (bool)",
];

export class MultiChainSweeperService {
  private readonly rpcUrlsByChainType: Record<string, string>;
  private readonly destinationByChainType: Record<string, string>;
  private readonly nativeReserveByChainType: Record<string, bigint>;
  private readonly feeFundingAddressByChainType: Record<string, string>;
  private readonly feeFundingPrivateKeyByChainType: Record<string, string>;
  private readonly evmChains = EVM_CHAIN_TYPES;

  constructor(
    rpcUrlsRaw: string | undefined,
    destinationsRaw: string | undefined,
    nativeReserveRaw: string | undefined,
    feeFundingAddressesRaw: string | undefined,
    feeFundingPrivateKeysRaw: string | undefined,
    private mappingStoreService: IMappingStore,
    private walletGenerationService: WalletGenerationService
  ) {
    this.rpcUrlsByChainType = parseChainStringMap(rpcUrlsRaw, "ALCHEMY_RPC_URLS");
    this.destinationByChainType = parseChainStringMap(destinationsRaw, "SWEEPER_DESTINATION_ADDRESSES");
    this.nativeReserveByChainType = this.parseBigIntMap(nativeReserveRaw, "SWEEPER_NATIVE_MIN_RESERVE");
    this.feeFundingAddressByChainType = parseChainStringMap(
      feeFundingAddressesRaw,
      "SWEEPER_FEE_FUNDING_ADDRESSES"
    );
    this.feeFundingPrivateKeyByChainType = parseChainStringMap(
      feeFundingPrivateKeysRaw,
      "SWEEPER_FEE_FUNDING_PRIVATE_KEYS"
    );
  }

  async withdraw(chainType: string, request: SweepWithdrawalRequest): Promise<SweepResult> {
    const normalizedChain = this.normalizeChainType(chainType);
    const userId = Number(request.user_id);
    if (!userId) {
      throw this.badRequest("user_id is required");
    }

    const sourceWallet = (await this.mappingStoreService
      .getByUserId(userId))
      ?.wallets.find((wallet) => wallet.chain_type === normalizedChain);

    if (!sourceWallet) {
      throw this.badRequest(`No tracked wallet found for user ${userId} on chain '${normalizedChain}'`);
    }

    const destinationAddress = String(
      request.destination_address || this.destinationByChainType[normalizedChain] || ""
    ).trim();
    if (!destinationAddress) {
      throw this.badRequest(`Missing destination_address for chain '${normalizedChain}'`);
    }

    if (normalizedChain === "solana") {
      return this.withdrawSolana(normalizedChain, userId, sourceWallet.address, destinationAddress, request);
    }

    if (this.evmChains.has(normalizedChain)) {
      return this.withdrawEvm(normalizedChain, userId, sourceWallet.address, destinationAddress, request);
    }

    throw this.badRequest(`Unsupported chain_type '${chainType}'`);
  }

  private async withdrawEvm(
    chainType: string,
    userId: number,
    sourceAddress: string,
    destinationAddress: string,
    request: SweepWithdrawalRequest
  ): Promise<SweepResult> {
    const provider = new JsonRpcProvider(this.getRpcUrl(chainType));
    const signer = this.walletGenerationService.deriveEvmWallet(chainType, userId).connect(provider);
    if (signer.address.toLowerCase() !== sourceAddress.toLowerCase()) {
      throw this.badRequest(`Derived signer address does not match tracked wallet for user ${userId}`);
    }

    const contractAddress = request.contract_address?.trim();
    if (contractAddress) {
      const contract = new Contract(contractAddress, ERC20_ABI, signer);
      const balance = BigInt((await contract.balanceOf(sourceAddress)).toString());
      const amount = this.resolveTokenAmount(balance, request.amount, request.sweep_all !== false);
      if (amount <= 0n) {
        throw this.badRequest("No token balance available to sweep");
      }

      await this.ensureEvmTokenGasFunding(
        chainType,
        provider,
        contractAddress,
        sourceAddress,
        destinationAddress,
        amount
      );

      const tx = await contract.transfer(destinationAddress, amount);
      await tx.wait();

      return {
        chain_type: chainType,
        user_id: userId,
        source_address: sourceAddress,
        destination_address: destinationAddress,
        asset: contractAddress,
        amount: amount.toString(10),
        tx_hash: tx.hash,
      };
    }

    const balance = await provider.getBalance(sourceAddress);
    const reserve = this.resolveNativeReserve(chainType, request.min_reserve);
    const nonce = await provider.getTransactionCount(sourceAddress, "pending");
    const feeData = await provider.getFeeData();
    const draft: TransactionRequest = {
      from: sourceAddress,
      to: destinationAddress,
      value: 0n,
      nonce,
      chainId: (await provider.getNetwork()).chainId,
      type: 2,
      maxFeePerGas: feeData.maxFeePerGas || feeData.gasPrice || 0n,
      maxPriorityFeePerGas: feeData.maxPriorityFeePerGas || 0n,
    };
    const gasLimit = BigInt((await provider.estimateGas(draft)).toString());
    const gasPrice = BigInt(String(draft.maxFeePerGas || feeData.gasPrice || 0n));
    const maxSpendable = balance - reserve - (gasLimit * gasPrice);
    if (maxSpendable <= 0n) {
      throw this.badRequest("Insufficient native balance to sweep after reserving gas");
    }

    const amount = this.resolveNativeAmount(maxSpendable, request.amount, request.sweep_all !== false);
    if (amount <= 0n || amount > maxSpendable) {
      throw this.badRequest("Requested native amount exceeds spendable balance");
    }

    const tx = await signer.sendTransaction({
      ...draft,
      gasLimit,
      value: amount,
    });
    await tx.wait();

    return {
      chain_type: chainType,
      user_id: userId,
      source_address: sourceAddress,
      destination_address: destinationAddress,
      asset: this.defaultAsset(chainType),
      amount: amount.toString(10),
      tx_hash: tx.hash,
    };
  }

  private async withdrawSolana(
    chainType: string,
    userId: number,
    sourceAddress: string,
    destinationAddress: string,
    request: SweepWithdrawalRequest
  ): Promise<SweepResult> {
    const connection = new Connection(this.getRpcUrl(chainType), "confirmed");
    const signer = this.walletGenerationService.deriveSolanaKeypair(userId);
    if (signer.publicKey.toBase58() !== sourceAddress) {
      throw this.badRequest(`Derived signer address does not match tracked wallet for user ${userId}`);
    }

    const contractAddress = request.contract_address?.trim();
    if (contractAddress) {
      const mint = this.parseSolanaPublicKey(contractAddress, "contract_address");
      const tokenProgramId = await this.resolveTokenProgramForMint(connection, mint);
      const sourceTokenAccount = await this.resolveSourceTokenAccount(
        connection,
        signer.publicKey,
        mint,
        tokenProgramId,
        contractAddress,
        sourceAddress
      );

      const destinationPublicKey = this.parseSolanaPublicKey(destinationAddress, "destination_address");
      const destinationTokenAccount = await this.resolveDestinationTokenAccount(
        connection,
        signer.publicKey,
        destinationPublicKey,
        mint,
        tokenProgramId
      );
      const balance = sourceTokenAccount.balance;
      const amount = this.resolveTokenAmount(balance, request.amount, request.sweep_all !== false);
      if (amount <= 0n) {
        throw this.badRequest("No token balance available to sweep");
      }

      const transferInstruction = await this.buildSolanaTransferInstruction(
        connection,
        sourceTokenAccount.address,
        destinationTokenAccount.address,
        signer.publicKey,
        mint,
        amount,
        tokenProgramId
      );

      await this.ensureSolanaTokenGasFunding(
        connection,
        signer.publicKey,
        mint,
        tokenProgramId,
        destinationTokenAccount.createInstruction,
        transferInstruction
      );

      const transaction = new Transaction();

      if (destinationTokenAccount.createInstruction) {
        transaction.add(destinationTokenAccount.createInstruction);
      }

      transaction.add(transferInstruction);

      const signature = await this.sendAndConfirmSolanaTransaction(connection, transaction, signer);
      return {
        chain_type: chainType,
        user_id: userId,
        source_address: sourceAddress,
        destination_address: destinationAddress,
        asset: contractAddress,
        amount: amount.toString(10),
        tx_hash: signature,
      };
    }

    const sourcePublicKey = signer.publicKey;
    const destinationPublicKey = this.parseSolanaPublicKey(destinationAddress, "destination_address");
    const balance = await connection.getBalance(sourcePublicKey, "confirmed");
    const latestBlockhash = await connection.getLatestBlockhash("confirmed");
    const draftTransaction = new Transaction({
      feePayer: sourcePublicKey,
      recentBlockhash: latestBlockhash.blockhash,
    }).add(
      SystemProgram.transfer({
        fromPubkey: sourcePublicKey,
        toPubkey: destinationPublicKey,
        lamports: 1,
      })
    );
    const estimatedFee = await draftTransaction.getEstimatedFee(connection);
    const fee = BigInt(estimatedFee || 5000);
    const reserve = this.resolveNativeReserve(chainType, request.min_reserve);
    const maxSpendable = BigInt(balance) - reserve - fee;
    if (maxSpendable <= 0n) {
      throw this.badRequest("Insufficient SOL balance to sweep after reserving fee");
    }

    const amount = this.resolveNativeAmount(maxSpendable, request.amount, request.sweep_all !== false);
    if (amount <= 0n || amount > maxSpendable) {
      throw this.badRequest("Requested SOL amount exceeds spendable balance");
    }

    const transaction = new Transaction({
      feePayer: sourcePublicKey,
      recentBlockhash: latestBlockhash.blockhash,
    }).add(
      SystemProgram.transfer({
        fromPubkey: sourcePublicKey,
        toPubkey: destinationPublicKey,
        lamports: amount,
      })
    );

    const signature = await this.sendAndConfirmSolanaTransaction(connection, transaction, signer);
    return {
      chain_type: chainType,
      user_id: userId,
      source_address: sourceAddress,
      destination_address: destinationAddress,
      asset: "SOL",
      amount: amount.toString(10),
      tx_hash: signature,
    };
  }

  private resolveTokenAmount(balance: bigint, rawAmount: string | number | undefined, sweepAll: boolean): bigint {
    if (rawAmount === undefined || rawAmount === null || rawAmount === "") {
      if (!sweepAll) {
        throw this.badRequest("amount is required when sweep_all is false");
      }
      return balance;
    }

    return this.parseIntegerAmount(rawAmount);
  }

  private resolveNativeAmount(maxSpendable: bigint, rawAmount: string | number | undefined, sweepAll: boolean): bigint {
    if (rawAmount === undefined || rawAmount === null || rawAmount === "") {
      if (!sweepAll) {
        throw this.badRequest("amount is required when sweep_all is false");
      }
      return maxSpendable;
    }

    return this.parseIntegerAmount(rawAmount);
  }

  private parseIntegerAmount(value: string | number): bigint {
    const normalized = String(value).trim();
    if (!/^[0-9]+$/.test(normalized)) {
      throw this.badRequest("amount must be provided in base units as a whole-number string");
    }
    return BigInt(normalized);
  }

  private parseSolanaPublicKey(value: string, fieldName: string): PublicKey {
    try {
      return new PublicKey(value);
    } catch (error) {
      const reason = error instanceof Error ? error.message : String(error);
      throw this.badRequest(`${fieldName} is not a valid Solana address${reason ? ` (${reason})` : ""}`);
    }
  }

  private async resolveDestinationTokenAccount(
    connection: Connection,
    payer: PublicKey,
    destinationPublicKey: PublicKey,
    mint: PublicKey,
    tokenProgramId: PublicKey
  ): Promise<{ address: PublicKey; createInstruction?: ReturnType<typeof createAssociatedTokenAccountInstruction> }> {
    try {
      const destinationAccount = await getAccount(connection, destinationPublicKey, "confirmed", tokenProgramId);
      if (destinationAccount.mint.equals(mint)) {
        return { address: destinationPublicKey };
      }

      throw this.badRequest("destination token account does not match the provided mint");
    } catch (error) {
      if (!(error instanceof TokenAccountNotFoundError)) {
        throw error;
      }
    }

    const associatedTokenAddress = await getAssociatedTokenAddress(
      mint,
      destinationPublicKey,
      true,
      tokenProgramId,
      ASSOCIATED_TOKEN_PROGRAM_ID
    );
    const associatedTokenInfo = await connection.getAccountInfo(associatedTokenAddress, "confirmed");
    if (associatedTokenInfo) {
      return { address: associatedTokenAddress };
    }

    return {
      address: associatedTokenAddress,
      createInstruction: createAssociatedTokenAccountInstruction(
        payer,
        associatedTokenAddress,
        destinationPublicKey,
        mint,
        tokenProgramId,
        ASSOCIATED_TOKEN_PROGRAM_ID
      ),
    };
  }

  private async resolveSourceTokenAccount(
    connection: Connection,
    owner: PublicKey,
    mint: PublicKey,
    tokenProgramId: PublicKey,
    contractAddress: string,
    sourceAddress: string
  ): Promise<{ address: PublicKey; balance: bigint }> {
    const associatedTokenAddress = await getAssociatedTokenAddress(
      mint,
      owner,
      false,
      tokenProgramId,
      ASSOCIATED_TOKEN_PROGRAM_ID
    );
    const associatedTokenInfo = await connection.getAccountInfo(associatedTokenAddress, "confirmed");

    if (associatedTokenInfo) {
      const balanceInfo = await connection.getTokenAccountBalance(associatedTokenAddress, "confirmed");
      return {
        address: associatedTokenAddress,
        balance: BigInt(balanceInfo.value.amount),
      };
    }

    const tokenAccounts = await connection.getParsedTokenAccountsByOwner(
      owner,
      { mint },
      "confirmed"
    );

    const firstFundedAccount = tokenAccounts.value.find((account) => {
      const rawAmount = account.account.data.parsed.info.tokenAmount.amount || "0";
      try {
        return BigInt(String(rawAmount)) > 0n;
      } catch {
        return false;
      }
    });

    if (!firstFundedAccount) {
      throw this.badRequest(`No source token account found for mint '${contractAddress}' on wallet '${sourceAddress}'`);
    }

    const rawAmount = firstFundedAccount.account.data.parsed.info.tokenAmount.amount || "0";

    return {
      address: firstFundedAccount.pubkey,
      balance: BigInt(String(rawAmount)),
    };
  }

  private async buildSolanaTransferInstruction(
    connection: Connection,
    sourceTokenAddress: PublicKey,
    destinationTokenAddress: PublicKey,
    authority: PublicKey,
    mint: PublicKey,
    amount: bigint,
    tokenProgramId: PublicKey
  ) {
    const mintInfo = await getMint(connection, mint, "confirmed", tokenProgramId);
    const transferFeeConfig = getTransferFeeConfig(mintInfo);

    if (transferFeeConfig) {
      const epochInfo = await connection.getEpochInfo("confirmed");
      const fee = calculateEpochFee(transferFeeConfig, BigInt(epochInfo.epoch), amount);

      return createTransferCheckedWithFeeInstruction(
        sourceTokenAddress,
        mint,
        destinationTokenAddress,
        authority,
        amount,
        mintInfo.decimals,
        fee,
        [],
        tokenProgramId
      );
    }

    return createTransferCheckedInstruction(
      sourceTokenAddress,
      mint,
      destinationTokenAddress,
      authority,
      amount,
      mintInfo.decimals,
      [],
      tokenProgramId
    );
  }

  private async resolveTokenProgramForMint(connection: Connection, mint: PublicKey): Promise<PublicKey> {
    const mintAccountInfo = await connection.getAccountInfo(mint, "confirmed");
    if (!mintAccountInfo) {
      throw this.badRequest(`Mint account not found for '${mint.toBase58()}'`);
    }

    if (mintAccountInfo.owner.equals(TOKEN_2022_PROGRAM_ID)) {
      return TOKEN_2022_PROGRAM_ID;
    }

    return TOKEN_PROGRAM_ID;
  }

  private async ensureEvmTokenGasFunding(
    chainType: string,
    provider: JsonRpcProvider,
    contractAddress: string,
    sourceAddress: string,
    destinationAddress: string,
    amount: bigint
  ): Promise<void> {
    const sourceBalance = await provider.getBalance(sourceAddress, "latest");
    const contract = new Contract(contractAddress, ERC20_ABI, provider);
    const data = contract.interface.encodeFunctionData("transfer", [destinationAddress, amount]);
    const feeData = await provider.getFeeData();
    const gasEstimate = BigInt(
      (
        await provider.estimateGas({
          from: sourceAddress,
          to: contractAddress,
          data,
        })
      ).toString()
    );
    const gasPrice = BigInt(String(feeData.maxFeePerGas || feeData.gasPrice || 0n));
    const requiredNative = gasEstimate * gasPrice;

    if (sourceBalance >= requiredNative) {
      return;
    }

    await this.fundEvmNativeShortfall(
      chainType,
      provider,
      sourceAddress,
      requiredNative - sourceBalance
    );
  }

  private async ensureSolanaTokenGasFunding(
    connection: Connection,
    sourcePublicKey: PublicKey,
    mint: PublicKey,
    tokenProgramId: PublicKey,
    createInstruction: ReturnType<typeof createAssociatedTokenAccountInstruction> | undefined,
    transferInstruction: Awaited<ReturnType<MultiChainSweeperService["buildSolanaTransferInstruction"]>>
  ): Promise<void> {
    const sourceBalance = BigInt(await connection.getBalance(sourcePublicKey, "confirmed"));
    const mintInfo = await getMint(connection, mint, "confirmed", tokenProgramId);
    const rentLamports = createInstruction
      ? BigInt(await connection.getMinimumBalanceForRentExemption(getAccountLenForMint(mintInfo), "confirmed"))
      : 0n;

    const latestBlockhash = await connection.getLatestBlockhash("confirmed");
    const transaction = new Transaction({
      feePayer: sourcePublicKey,
      recentBlockhash: latestBlockhash.blockhash,
    });

    if (createInstruction) {
      transaction.add(createInstruction);
    }

    transaction.add(transferInstruction);

    const feeResponse = await connection.getFeeForMessage(transaction.compileMessage(), "confirmed");
    const feeLamports = BigInt(feeResponse.value || 5000);
    const requiredLamports = feeLamports + rentLamports;

    if (sourceBalance >= requiredLamports) {
      return;
    }

    await this.fundSolanaNativeShortfall(
      connection,
      sourcePublicKey,
      requiredLamports - sourceBalance
    );
  }

  private async fundEvmNativeShortfall(
    chainType: string,
    provider: JsonRpcProvider,
    destinationAddress: string,
    shortfall: bigint
  ): Promise<void> {
    const fundingWallet = this.getEvmFundingWallet(chainType, provider);
    const fundingBalance = await provider.getBalance(fundingWallet.address, "latest");
    if (fundingBalance < shortfall) {
      throw this.badRequest(`Fee funding wallet for '${chainType}' does not have enough native balance`);
    }

    const tx = await fundingWallet.sendTransaction({
      to: destinationAddress,
      value: shortfall,
    });

    await tx.wait();
  }

  private async fundSolanaNativeShortfall(
    connection: Connection,
    destinationPublicKey: PublicKey,
    shortfall: bigint
  ): Promise<void> {
    const fundingKeypair = this.getSolanaFundingKeypair();
    const fundingBalance = BigInt(await connection.getBalance(fundingKeypair.publicKey, "confirmed"));
    if (fundingBalance < shortfall) {
      throw this.badRequest("Solana fee funding wallet does not have enough SOL balance");
    }

    const transaction = new Transaction().add(
      SystemProgram.transfer({
        fromPubkey: fundingKeypair.publicKey,
        toPubkey: destinationPublicKey,
        lamports: shortfall,
      })
    );

    await this.sendAndConfirmSolanaTransaction(connection, transaction, fundingKeypair);
  }

  private getEvmFundingWallet(chainType: string, provider: JsonRpcProvider): Wallet {
    const privateKey = this.feeFundingPrivateKeyByChainType[chainType];
    if (!privateKey) {
      throw this.badRequest(`Missing fee funding private key for chain '${chainType}'`);
    }

    const wallet = new Wallet(privateKey, provider);
    const configuredAddress = this.feeFundingAddressByChainType[chainType];
    if (configuredAddress && wallet.address.toLowerCase() !== configuredAddress.toLowerCase()) {
      throw this.badRequest(`Fee funding wallet address mismatch for chain '${chainType}'`);
    }

    return wallet;
  }

  private getSolanaFundingKeypair(): Keypair {
    const privateKey = this.feeFundingPrivateKeyByChainType.solana;
    if (!privateKey) {
      throw this.badRequest("Missing fee funding private key for chain 'solana'");
    }

    const keypair = this.parseSolanaSecretKey(privateKey);
    const configuredAddress = this.feeFundingAddressByChainType.solana;
    if (configuredAddress && keypair.publicKey.toBase58() !== configuredAddress) {
      throw this.badRequest("Fee funding wallet address mismatch for chain 'solana'");
    }

    return keypair;
  }

  private parseSolanaSecretKey(value: string): Keypair {
    const normalized = value.trim();

    try {
      if (normalized.startsWith("[")) {
        const parsed = JSON.parse(normalized);
        if (Array.isArray(parsed)) {
          return Keypair.fromSecretKey(Uint8Array.from(parsed));
        }
      }

      if (normalized.includes(",")) {
        return Keypair.fromSecretKey(
          Uint8Array.from(
            normalized.split(",").map((part) => Number(part.trim()))
          )
        );
      }

      return Keypair.fromSecretKey(Uint8Array.from(Buffer.from(normalized, "base64")));
    } catch {
      throw this.badRequest("Invalid Solana fee funding private key format");
    }
  }

  private async sendAndConfirmSolanaTransaction(
    connection: Connection,
    transaction: Transaction,
    signer: { publicKey: PublicKey; secretKey: Uint8Array }
  ): Promise<string> {
    const { blockhash, lastValidBlockHeight } = await connection.getLatestBlockhash("confirmed");
    transaction.recentBlockhash = blockhash;
    transaction.feePayer = signer.publicKey;

    const signature = await connection.sendTransaction(transaction, [signer], {
      preflightCommitment: "confirmed",
    });

    const confirmation = await connection.confirmTransaction(
      {
        signature,
        blockhash,
        lastValidBlockHeight,
      },
      "confirmed"
    );

    if (confirmation.value.err) {
      throw new Error(`Solana transaction failed: ${JSON.stringify(confirmation.value.err)}`);
    }

    return signature;
  }

  private resolveNativeReserve(chainType: string, override?: string | number): bigint {
    if (override !== undefined && override !== null && override !== "") {
      return this.parseIntegerAmount(override);
    }

    return this.nativeReserveByChainType[chainType] || 0n;
  }

  private getRpcUrl(chainType: string): string {
    const url = this.rpcUrlsByChainType[chainType];
    if (!url) {
      throw this.badRequest(`Missing Alchemy RPC URL for chain '${chainType}'`);
    }
    return url;
  }

  private defaultAsset(chainType: string): string {
    if (chainType === "solana") {
      return "SOL";
    }
    if (chainType === "bsc") {
      return "BNB";
    }
    return "ETH";
  }

  private normalizeChainType(chainType: string): string {
    try {
      return normalizeRequiredChainType(chainType);
    } catch {
      throw this.badRequest("chain_type is required");
    }
  }

  private parseBigIntMap(raw: string | undefined, envName: string): Record<string, bigint> {
    if (!raw) {
      return {};
    }

    try {
      const parsed = JSON.parse(raw) as Record<string, unknown>;
      return Object.entries(parsed).reduce<Record<string, bigint>>((accumulator, [key, value]) => {
        if (typeof value === "string" || typeof value === "number") {
          accumulator[this.normalizeChainType(key)] = this.parseIntegerAmount(value);
        }
        return accumulator;
      }, {});
    } catch {
      throw new Error(`${envName} must be valid JSON`);
    }
  }

  private badRequest(message: string): Error & { statusCode: number } {
    const error = new Error(message) as Error & { statusCode: number };
    error.statusCode = 400;
    return error;
  }
}
