import {
  AccountId,
  Hbar,
  HbarUnit,
  Long,
  TokenAssociateTransaction,
  TransferTransaction,
  AccountUpdateTransaction,
  ContractExecuteTransaction,
  ContractFunctionParameters,
} from "@hashgraph/sdk";
import type { FeeEstimation } from "@ledgerhq/coin-module-framework/api/types";
import { setupCalClientStore } from "@ledgerhq/cryptoassets/cal-client/test-helpers";
import { getEnv } from "@ledgerhq/live-env";
import invariant from "invariant";
import { createApi } from "../api";
import { HEDERA_TRANSACTION_MODES, STAKING_REWARD_HASH_SUFFIX, TINYBAR_SCALE } from "../constants";
import { getSyntheticBlock, toEVMAddress } from "../logic/utils";
import { rpcClient } from "../network/rpc";
import { MAINNET_TEST_ACCOUNTS } from "../test/fixtures/account.fixture";

describe("createApi", () => {
  const api = createApi({ useHgraphForErc20: true, useNetworkTimestamp: true }, "hedera");

  beforeAll(() => {
    // Setup CAL client store (automatically set as global store)
    setupCalClientStore();
  });

  afterAll(async () => {
    await rpcClient._resetInstance();
  });

  describe("craftTransaction", () => {
    it("returns serialized native coin TransferTransaction", async () => {
      const { transaction: hex } = await api.craftTransaction({
        intentType: "transaction",
        asset: {
          type: "native",
        },
        type: HEDERA_TRANSACTION_MODES.Send,
        amount: BigInt(1 * 10 ** TINYBAR_SCALE),
        sender: MAINNET_TEST_ACCOUNTS.withoutTokens.accountId,
        senderPublicKey: MAINNET_TEST_ACCOUNTS.withoutTokens.publicKey,
        recipient: MAINNET_TEST_ACCOUNTS.withTokens.accountId,
        memo: {
          kind: "text",
          type: "string",
          value: "native transfer",
        },
      });

      const rawTx = TransferTransaction.fromBytes(Buffer.from(hex, "hex"));

      expect(rawTx).toBeInstanceOf(TransferTransaction);
      invariant(rawTx instanceof TransferTransaction, "TransferTransaction type guard");

      const sendTransfer = rawTx.hbarTransfers.get(MAINNET_TEST_ACCOUNTS.withoutTokens.accountId);
      const receiveTransfer = rawTx.hbarTransfers.get(MAINNET_TEST_ACCOUNTS.withTokens.accountId);

      expect(rawTx.hbarTransfers.size).toBe(2);
      expect(sendTransfer).toEqual(Hbar.from(-1, HbarUnit.Hbar));
      expect(receiveTransfer).toEqual(Hbar.from(1, HbarUnit.Hbar));
      expect(rawTx.transactionMemo).toBe("native transfer");
    });

    it("returns serialized HTS token TransferTransaction", async () => {
      const { transaction: hex } = await api.craftTransaction({
        intentType: "transaction",
        asset: {
          type: "hts",
          assetReference: "0.0.5022567",
        },
        type: HEDERA_TRANSACTION_MODES.Send,
        amount: BigInt(1),
        sender: MAINNET_TEST_ACCOUNTS.withoutTokens.accountId,
        senderPublicKey: MAINNET_TEST_ACCOUNTS.withoutTokens.publicKey,
        recipient: MAINNET_TEST_ACCOUNTS.withTokens.accountId,
        memo: {
          kind: "text",
          type: "string",
          value: "hts token transfer",
        },
      });

      const rawTx = TransferTransaction.fromBytes(Buffer.from(hex, "hex"));

      expect(rawTx).toBeInstanceOf(TransferTransaction);
      invariant(rawTx instanceof TransferTransaction, "TransferTransaction type guard");

      const tokenTransfers = rawTx.tokenTransfers.get("0.0.5022567");
      const senderTransfer = tokenTransfers?.get(MAINNET_TEST_ACCOUNTS.withoutTokens.accountId);
      const recipientTransfer = tokenTransfers?.get(MAINNET_TEST_ACCOUNTS.withTokens.accountId);

      expect(senderTransfer).toEqual(Long.fromNumber(-1));
      expect(recipientTransfer).toEqual(Long.fromNumber(1));
      expect(tokenTransfers).not.toBeNull();
      expect(rawTx.transactionMemo).toBe("hts token transfer");
    });

    it("returns serialized ERC20 token ContractExecuteTransaction", async () => {
      const { transaction: hex } = await api.craftTransaction({
        intentType: "transaction",
        asset: {
          type: "erc20",
          assetReference: "0xca367694cdac8f152e33683bb36cc9d6a73f1ef2",
        },
        type: HEDERA_TRANSACTION_MODES.Send,
        amount: 1n,
        sender: MAINNET_TEST_ACCOUNTS.withoutTokens.accountId,
        senderPublicKey: MAINNET_TEST_ACCOUNTS.withoutTokens.publicKey,
        recipient: MAINNET_TEST_ACCOUNTS.withTokens.accountId,
        memo: {
          kind: "text",
          type: "string",
          value: "erc20 token transfer",
        },
        data: {
          type: "erc20",
          gasLimit: 100n,
        },
      } as any);

      const rawTx = ContractExecuteTransaction.fromBytes(Buffer.from(hex, "hex"));
      expect(rawTx).toBeInstanceOf(ContractExecuteTransaction);
      invariant(
        rawTx instanceof ContractExecuteTransaction,
        "ContractExecuteTransaction type guard",
      );

      const recipientEvmAddress = await toEVMAddress(MAINNET_TEST_ACCOUNTS.withTokens.accountId);
      invariant(recipientEvmAddress, "hedera: missing recipient EVM address");
      const expectedFunctionParameters = new ContractFunctionParameters()
        .addAddress(recipientEvmAddress)
        .addUint256(1);

      expect(rawTx.gas).toEqual(Long.fromNumber(100));
      expect(rawTx.transactionMemo).toBe("erc20 token transfer");
      expect(rawTx.functionParameters).toEqual(
        Buffer.concat([
          Buffer.from([0xa9, 0x05, 0x9c, 0xbb]), // transfer(address,uint256) selector
          Buffer.from(expectedFunctionParameters._build()), // address + amount parameters
        ]),
      );
    });

    it("returns serialized HTS token association transaction", async () => {
      const { transaction: hex } = await api.craftTransaction({
        intentType: "transaction",
        asset: {
          type: "hts",
          assetReference: "0.0.5022567",
        },
        amount: BigInt(0),
        sender: MAINNET_TEST_ACCOUNTS.withoutTokens.accountId,
        senderPublicKey: MAINNET_TEST_ACCOUNTS.withoutTokens.publicKey,
        recipient: MAINNET_TEST_ACCOUNTS.withoutTokens.accountId,
        type: HEDERA_TRANSACTION_MODES.TokenAssociate,
        memo: {
          kind: "text",
          type: "string",
          value: "token association",
        },
      });

      const rawTx = TokenAssociateTransaction.fromBytes(Buffer.from(hex, "hex"));

      expect(rawTx).toBeInstanceOf(TokenAssociateTransaction);
      invariant(rawTx instanceof TokenAssociateTransaction, "TokenAssociateTransaction type guard");
      expect(rawTx.accountId).toEqual(
        AccountId.fromString(MAINNET_TEST_ACCOUNTS.withoutTokens.accountId),
      );
      // .toString() is used because sdk.TokenId.fromString() sets `_checksum` to undefined,
      // where tokenIds elements from TokenAssociateTransaction.fromBytes have it set to null
      expect(rawTx.tokenIds?.[0]?.toString()).toEqual("0.0.5022567");
      expect(rawTx.transactionMemo).toBe("token association");
    });

    it.each([HEDERA_TRANSACTION_MODES.Delegate, HEDERA_TRANSACTION_MODES.Undelegate])(
      "returns serialized %s transaction",
      async type => {
        const { transaction: hex } = await api.craftTransaction({
          intentType: "transaction",
          asset: {
            type: "native",
          },
          type,
          amount: BigInt(0),
          sender: MAINNET_TEST_ACCOUNTS.withoutTokens.accountId,
          senderPublicKey: MAINNET_TEST_ACCOUNTS.withoutTokens.publicKey,
          recipient: MAINNET_TEST_ACCOUNTS.withoutTokens.accountId,
          memo: {
            kind: "text",
            type: "string",
            value: type,
          },
        });

        const rawTx = AccountUpdateTransaction.fromBytes(Buffer.from(hex, "hex"));

        expect(rawTx).toBeInstanceOf(AccountUpdateTransaction);
        invariant(rawTx instanceof AccountUpdateTransaction, "AccountUpdateTransaction type guard");
        expect(rawTx.accountId).toEqual(
          AccountId.fromString(MAINNET_TEST_ACCOUNTS.withoutTokens.accountId),
        );
        expect(rawTx.transactionMemo).toBe(type);
      },
    );

    it("applies customFees properly", async () => {
      const customFees: FeeEstimation = {
        value: BigInt(1000),
      };

      const { transaction: hex } = await api.craftTransaction(
        {
          intentType: "transaction",
          asset: {
            type: "native",
          },
          amount: BigInt(1 * 10 ** TINYBAR_SCALE),
          sender: MAINNET_TEST_ACCOUNTS.withoutTokens.accountId,
          senderPublicKey: MAINNET_TEST_ACCOUNTS.withoutTokens.publicKey,
          recipient: MAINNET_TEST_ACCOUNTS.withTokens.accountId,
          type: HEDERA_TRANSACTION_MODES.Send,
          memo: {
            kind: "text",
            type: "string",
            value: "",
          },
        },
        customFees,
      );

      const rawTx = TransferTransaction.fromBytes(Buffer.from(hex, "hex"));
      const expectedMaxFee = Hbar.from(customFees.value.toString(), HbarUnit.Tinybar);

      expect(rawTx).toBeInstanceOf(TransferTransaction);
      invariant(rawTx instanceof TransferTransaction, "TransferTransaction type guard");
      expect(rawTx.maxTransactionFee).toEqual(expectedMaxFee);
    });

    it("throws if useAllAmount is true", async () => {
      await expect(
        api.craftTransaction({
          intentType: "transaction",
          asset: {
            type: "native",
          },
          amount: BigInt(100),
          useAllAmount: true,
          sender: MAINNET_TEST_ACCOUNTS.withoutTokens.accountId,
          senderPublicKey: MAINNET_TEST_ACCOUNTS.withoutTokens.publicKey,
          recipient: MAINNET_TEST_ACCOUNTS.withoutTokens.accountId,
          type: HEDERA_TRANSACTION_MODES.TokenAssociate,
          memo: {
            kind: "text",
            type: "string",
            value: "token association",
          },
        }),
      ).rejects.toThrow("useAllAmount is not supported");
    });
  });

  describe("estimateFees", () => {
    it("returns fee for coin transfer transaction", async () => {
      const fees = await api.estimateFees({
        intentType: "transaction",
        asset: {
          type: "native",
        },
        type: HEDERA_TRANSACTION_MODES.Send,
        sender: MAINNET_TEST_ACCOUNTS.withoutTokens.accountId,
        senderPublicKey: MAINNET_TEST_ACCOUNTS.withoutTokens.publicKey,
        amount: BigInt(100),
        recipient: MAINNET_TEST_ACCOUNTS.withTokens.accountId,
        memo: {
          kind: "text",
          type: "string",
          value: "",
        },
      });

      expect(fees.value).toBeGreaterThanOrEqual(0n);
    });

    it("returns fee for HTS token transfer transaction", async () => {
      const fees = await api.estimateFees({
        intentType: "transaction",
        asset: {
          type: "hts",
          assetReference: "0.0.5022567",
        },
        type: HEDERA_TRANSACTION_MODES.Send,
        sender: MAINNET_TEST_ACCOUNTS.withoutTokens.accountId,
        senderPublicKey: MAINNET_TEST_ACCOUNTS.withoutTokens.publicKey,
        amount: BigInt(100),
        recipient: MAINNET_TEST_ACCOUNTS.withTokens.accountId,
        memo: {
          kind: "text",
          type: "string",
          value: "",
        },
      });

      expect(fees.value).toBeGreaterThanOrEqual(0n);
    });

    it("returns fee for ERC20 token transfer transaction", async () => {
      const fees = await api.estimateFees({
        intentType: "transaction",
        asset: {
          type: "erc20",
          assetReference: "0xca367694cdac8f152e33683bb36cc9d6a73f1ef2",
        },
        type: HEDERA_TRANSACTION_MODES.Send,
        sender: MAINNET_TEST_ACCOUNTS.withoutTokens.accountId,
        senderPublicKey: MAINNET_TEST_ACCOUNTS.withoutTokens.publicKey,
        amount: 100n,
        recipient: MAINNET_TEST_ACCOUNTS.withTokens.accountId,
        memo: {
          kind: "text",
          type: "string",
          value: "",
        },
      });

      expect(fees.value).toBeGreaterThanOrEqual(0n);
    });

    it("returns fee for token association transaction", async () => {
      const fees = await api.estimateFees({
        intentType: "transaction",
        asset: {
          type: "hts",
          assetReference: "0.0.5022567",
        },
        type: HEDERA_TRANSACTION_MODES.TokenAssociate,
        sender: MAINNET_TEST_ACCOUNTS.withoutTokens.accountId,
        senderPublicKey: MAINNET_TEST_ACCOUNTS.withoutTokens.publicKey,
        amount: BigInt(100),
        recipient: MAINNET_TEST_ACCOUNTS.withTokens.accountId,
        memo: {
          kind: "text",
          type: "string",
          value: "",
        },
      });

      expect(fees.value).toBeGreaterThanOrEqual(0n);
    });

    it.each([
      HEDERA_TRANSACTION_MODES.Delegate,
      HEDERA_TRANSACTION_MODES.Undelegate,
      HEDERA_TRANSACTION_MODES.ClaimRewards,
      HEDERA_TRANSACTION_MODES.Redelegate,
    ])("returns fee for %s transaction", async type => {
      const fees = await api.estimateFees({
        intentType: "transaction",
        asset: {
          type: "native",
        },
        type,
        sender: MAINNET_TEST_ACCOUNTS.withoutTokens.accountId,
        senderPublicKey: MAINNET_TEST_ACCOUNTS.withoutTokens.publicKey,
        amount: BigInt(100),
        recipient: MAINNET_TEST_ACCOUNTS.withTokens.accountId,
        memo: {
          kind: "text",
          type: "string",
          value: type,
        },
      });

      expect(fees.value).toBeGreaterThanOrEqual(0n);
    });
  });

  describe("getBalance", () => {
    it("returns zero balance for pristine account", async () => {
      const balances = await api.getBalance(MAINNET_TEST_ACCOUNTS.pristine.accountId);

      expect(balances.length).toBe(1);
      expect(balances[0].value).toBe(0n);
    });

    it("returns empty result for non-existent account", async () => {
      const balances = await api.getBalance("0.0.0");

      expect(balances).toEqual([]);
    });

    it("returns native asset for account without tokens", async () => {
      const balances = await api.getBalance(MAINNET_TEST_ACCOUNTS.withoutTokens.accountId);
      const nativeBalance = balances.filter(b => b.asset.type === "native");

      expect(nativeBalance.length).toBe(1);
      expect(nativeBalance[0].value).toBeGreaterThan(0n);
    });

    it("returns native and token assets for account with tokens", async () => {
      const balances = await api.getBalance(MAINNET_TEST_ACCOUNTS.withTokens.accountId);
      const tokenBalances = balances.filter(b => b.asset.type !== "native");

      const associatedTokenWithBalance = balances.find(b => {
        return (
          "assetReference" in b.asset &&
          b.asset.assetReference === MAINNET_TEST_ACCOUNTS.withTokens.associatedTokenWithBalance
        );
      });

      const associatedTokenWithoutBalance = balances.find(b => {
        return (
          "assetReference" in b.asset &&
          b.asset.assetReference === MAINNET_TEST_ACCOUNTS.withTokens.associatedTokenWithoutBalance
        );
      });

      const notAssociatedToken = balances.find(b => {
        return (
          "assetReference" in b.asset &&
          b.asset.assetReference === MAINNET_TEST_ACCOUNTS.withTokens.notAssociatedToken
        );
      });

      const erc20TokenBalance = balances.find(b => {
        return (
          "assetReference" in b.asset &&
          b.asset.assetReference === MAINNET_TEST_ACCOUNTS.withTokens.erc20Token
        );
      });

      expect(tokenBalances.length).toBeGreaterThan(0);
      expect(associatedTokenWithBalance?.value).toBeGreaterThan(0n);
      expect(associatedTokenWithoutBalance?.value).toBe(0n);
      expect(notAssociatedToken?.value).toBe(undefined);
      expect(erc20TokenBalance?.value).toBeGreaterThan(0n);
    });

    it("returns stake information for delegated account", async () => {
      const balances = await api.getBalance(MAINNET_TEST_ACCOUNTS.activeStaking.accountId);
      const nativeBalance = balances.find(b => b.asset.type === "native");

      expect(nativeBalance?.stake).toMatchObject({
        uid: MAINNET_TEST_ACCOUNTS.activeStaking.accountId,
        address: MAINNET_TEST_ACCOUNTS.activeStaking.accountId,
        asset: { type: "native" },
        state: "active",
        amount: expect.any(BigInt),
        amountDeposited: expect.any(BigInt),
        amountRewarded: expect.any(BigInt),
        delegate: expect.any(String),
      });
    });

    it("returns no stake information for non-delegated account", async () => {
      const balances = await api.getBalance(MAINNET_TEST_ACCOUNTS.inactiveStaking.accountId);
      const nativeBalance = balances.find(b => b.asset.type === "native");

      expect(nativeBalance?.stake).toBe(undefined);
    });
  });

  describe("getBlock", () => {
    it("returns block with proper multi-transfer data", async () => {
      const blockHeight = 176051087;
      const multiTransferTxHash =
        "OoaJ/10qHN/97Zaxj8vxGIJfL9UhrGKaJBwclsL4wUeqbegBAXhdmw+/6/dB6mow";

      const expectedCoinTransferTx = {
        hash: multiTransferTxHash,
        failed: false,
        fees: 1176695n,
        feesPayer: "0.0.8835924",
        operations: [
          {
            type: "transfer",
            address: "0.0.15",
            asset: {
              type: "native",
            },
            amount: 55631n,
          },
          {
            type: "transfer",
            address: "0.0.801",
            asset: {
              type: "native",
            },
            amount: 1121064n,
          },
          {
            type: "transfer",
            address: "0.0.8835924",
            asset: {
              type: "native",
            },
            amount: -2000000n, // -3176695n + 1176695n fee
          },
          {
            type: "transfer",
            address: "0.0.9124531",
            asset: {
              type: "native",
            },
            amount: 1000000n,
          },
          {
            type: "transfer",
            address: "0.0.9169746",
            asset: {
              type: "native",
            },
            amount: 1000000n,
          },
          {
            type: "transfer",
            address: "0.0.8835924",
            asset: {
              type: "hts",
              assetReference: "0.0.456858",
            },
            amount: -10000n,
          },
          {
            type: "transfer",
            address: "0.0.9124531",
            asset: {
              type: "hts",
              assetReference: "0.0.456858",
            },
            amount: 10000n,
          },
          {
            type: "transfer",
            address: "0.0.8835924",
            asset: {
              type: "hts",
              assetReference: "0.0.5022567",
            },
            amount: -2n,
          },
          {
            type: "transfer",
            address: "0.0.9124531",
            asset: {
              type: "hts",
              assetReference: "0.0.5022567",
            },
            amount: 1n,
          },
          {
            type: "transfer",
            address: "0.0.9169746",
            asset: {
              type: "hts",
              assetReference: "0.0.5022567",
            },
            amount: 1n,
          },
        ],
      };

      const block = await api.getBlock(blockHeight);
      const resultCoinTransferTx = block.transactions.find(tx => tx.hash === multiTransferTxHash);

      expect(block.info.height).toBe(blockHeight);
      expect(block.info.hash?.length).toBe(64);
      expect(block.info.time).toBeInstanceOf(Date);
      expect(block.info.time?.getTime()).toBeGreaterThan(0);
      expect(resultCoinTransferTx).toMatchObject(expectedCoinTransferTx);
      expect(block.transactions).toBeInstanceOf(Array);
      expect(block.transactions.length).toEqual(48);
      block.transactions.forEach(tx => {
        expect(tx.hash.length).toBe(64);
        expect(tx.fees).toBeGreaterThanOrEqual(0n);
      });
    });

    it("returns block with transaction memo", async () => {
      const blockHeight = 176180671;
      const txHash = "4Ksb7RTwtvvk9r6vvK0Gwxb38kwPqVbJjP6bL4bu2gTvdwrIGZGk6TWntlgRsjvU";

      const block = await api.getBlock(blockHeight);
      const transaction = block.transactions.find(tx => tx.hash === txHash);

      expect(transaction?.details?.memo).toBe("test");
    });

    it("derives fees payer from transfers for failed transactions", async () => {
      const blockHeight = 176175512;
      const txPaidBySender = "zlE5fX0N44XgMzi9jxr9G4gcCwuAQ4v75wYVXmqBqE808wLKhc/aS+3ZZFl1XOzp";
      const txNotPaidBySender = "su9qFNvTpteObMCdqJZ8UxKmgB0UFafqPbwjpawBKzAzJOPwCgpQz6TLCL80oZXd";

      const block = await api.getBlock(blockHeight);
      const firstTx = block.transactions.find(tx => tx.hash === txPaidBySender);
      const secondTx = block.transactions.find(tx => tx.hash === txNotPaidBySender);

      expect(firstTx?.failed).toBe(true);
      expect(firstTx?.feesPayer).toBe("0.0.10067173");

      expect(secondTx?.failed).toBe(true);
      expect(secondTx?.feesPayer).toBe("0.0.23");
    });

    it("correctly identifies erc20 operations in blocks", async () => {
      const blockHeight = 176814261;
      const txHash = "dN7BMus6+8ISOwNPVt7l4KpQT9VaSM9LG6qLPXBqpRVw83ZPMO6Bzyt63305lLXu";

      const block = await api.getBlock(blockHeight);
      const transaction = block.transactions.find(tx => tx.hash === txHash);

      expect(transaction?.fees).toBe(BigInt(3741416));
      expect(transaction?.operations).toEqual(
        expect.arrayContaining([
          {
            type: "transfer",
            address: "0.0.801",
            asset: {
              type: "native",
            },
            amount: 3741416n,
          },
          {
            type: "transfer",
            address: "0.0.8835924",
            asset: {
              type: "native",
            },
            amount: 0n,
          },
          {
            type: "transfer",
            address: "0.0.9124531",
            asset: {
              type: "erc20",
              assetReference: "0xca367694cdac8f152e33683bb36cc9d6a73f1ef2",
            },
            amount: 7770000000000n,
          },
          {
            type: "transfer",
            address: "0.0.8835924",
            asset: {
              type: "erc20",
              assetReference: "0xca367694cdac8f152e33683bb36cc9d6a73f1ef2",
            },
            amount: -7770000000000n,
          },
        ]),
      );
    });

    it("correctly identifies staking operations in blocks", async () => {
      const [delegateBlock, undelegateBlock, redelegateBlock, rewardsBlock] = await Promise.all([
        api.getBlock(176220207),
        api.getBlock(176220201),
        api.getBlock(176220211),
        api.getBlock(176777078),
      ]);

      const delegateOperations = delegateBlock.transactions
        .flatMap(tx => tx.operations)
        .filter(op => op.type === "other");
      const undelegateOperations = undelegateBlock.transactions
        .flatMap(tx => tx.operations)
        .filter(op => op.type === "other");
      const redelegateOperations = redelegateBlock.transactions
        .flatMap(tx => tx.operations)
        .filter(op => op.type === "other");
      const rewardsTransaction = rewardsBlock.transactions.find(
        tx => tx.hash === "dwKzBC5qV79SxlRufB6yfXIVOrNh9Nswt36zDoxRgwOQaKmjDHJlM5ImKxSnnRgs",
      );

      expect(delegateOperations).toEqual([
        {
          type: "other",
          operationType: "DELEGATE",
          stakedNodeId: 34,
          previousStakedNodeId: null,
          stakedAmount: BigInt(21083322293),
        },
      ]);
      expect(undelegateOperations).toEqual([
        {
          type: "other",
          operationType: "UNDELEGATE",
          stakedNodeId: null,
          previousStakedNodeId: 22,
          stakedAmount: BigInt(21083441623),
        },
      ]);
      expect(redelegateOperations).toEqual([
        {
          type: "other",
          operationType: "REDELEGATE",
          stakedNodeId: 6,
          previousStakedNodeId: 34,
          stakedAmount: BigInt(21083202902),
        },
      ]);
      expect(rewardsTransaction?.operations).toEqual(
        expect.arrayContaining([
          {
            type: "transfer",
            address: "0.0.35",
            asset: {
              type: "native",
            },
            amount: 3235n,
          },
          {
            type: "transfer",
            address: "0.0.800",
            asset: {
              type: "native",
            },
            amount: -30505446n,
          },
          {
            type: "transfer",
            address: "0.0.801",
            asset: {
              type: "native",
            },
            amount: 76639n,
          },
          {
            type: "transfer",
            address: "0.0.8835924",
            asset: {
              type: "native",
            },
            amount: -1000000n, // excluded fee and staking reward
          },
          {
            type: "transfer",
            address: "0.0.9124531",
            asset: {
              type: "native",
            },
            amount: 1000000n, // excluded staking reward
          },
          {
            type: "transfer",
            address: "0.0.8835924",
            asset: {
              type: "native",
            },
            amount: 30313674n,
          },
          {
            type: "transfer",
            address: "0.0.9124531",
            asset: {
              type: "native",
            },
            amount: 191772n,
          },
        ]),
      );
    });

    it("returns block for latest finalized height from lastBlock", async () => {
      const latestBlockInfo = await api.lastBlock();
      const block = await api.getBlock(latestBlockInfo.height);

      expect(block.info.height).toBe(latestBlockInfo.height);
      expect(block.info.hash).toBe(latestBlockInfo.hash);
      // Note: lastBlock().time is the transaction timestamp, while getBlock().info.time is the block start time
      expect(block.info.time).toBeInstanceOf(Date);
      expect(block.transactions).toBeInstanceOf(Array);
    });

    it("returns single transaction for multiple erc20 transfers", async () => {
      const data = await api.getBlock(177314999);

      const erc20Asset = {
        type: "erc20",
        assetReference: "0xb7687538c7f4cad022d5e97cc778d0b46457c5db",
      };
      const erc20TxHash = "givnas3WAL3fiGeap+oSRIYOqUbqE0Ig2XIMTWgTDQzTMc8g7aOC1vxc8hQy7wZX";
      const filteredTransactions = data.transactions.filter(tx => tx.hash === erc20TxHash);

      expect(filteredTransactions).toEqual([
        expect.objectContaining({
          operations: [
            {
              type: "transfer",
              address: "0.0.802",
              asset: {
                type: "native",
              },
              amount: 26596592n,
            },
            {
              type: "transfer",
              address: "0.0.10067136",
              asset: {
                type: "native",
              },
              amount: 0n,
            },
            {
              type: "transfer",
              address: "0.0.6145236",
              asset: erc20Asset,
              amount: 2863838n,
            },
            {
              type: "transfer",
              address: "0x0000000000000000000000000000000000000000",
              asset: erc20Asset,
              amount: -2863838n,
            },
            {
              type: "transfer",
              address: "0.0.10067136",
              asset: erc20Asset,
              amount: 148440n,
            },
            {
              type: "transfer",
              address: "0x0000000000000000000000000000000000000000",
              asset: erc20Asset,
              amount: -148440n,
            },
          ],
          fees: 26596592n,
          feesPayer: "0.0.10067136",
        }),
      ]);
    });

    it("filters out ERC20 operations with null sender or recipient address", async () => {
      const blockHeight = 177564534;
      const txHashWithNullAddress =
        "tSFV6McHlh0v6tZEZVGlwavk/QRoMabPIOtVbyJ1/j3gvTHMZP97URu4Vw6JbMmC";

      const block = await api.getBlock(blockHeight);
      const transaction = block.transactions.find(tx => tx.hash === txHashWithNullAddress);
      const operationAddresses = transaction?.operations.map(op => op.address);

      expect(transaction).not.toBeUndefined();
      expect(transaction?.operations.length).toBeGreaterThan(0);
      expect(operationAddresses).not.toContain(null);
    });
  });

  describe("lastBlock", () => {
    it("returns the last block information", async () => {
      const lastBlock = await api.lastBlock();

      expect(lastBlock.height).toBeGreaterThan(0);
      expect(lastBlock.hash?.length).toBe(64);
      expect(lastBlock.time?.getTime()).toBeGreaterThan(0);
    });
  });

  describe("listOperations", () => {
    const rewardPayerAddress = getEnv("HEDERA_STAKING_REWARD_ACCOUNT_ID");

    it("returns empty array for pristine account", async () => {
      const { items: operations } = await api.listOperations(
        MAINNET_TEST_ACCOUNTS.pristine.accountId,
        { minHeight: 0, order: "desc" },
      );

      expect(operations).toBeInstanceOf(Array);
      expect(operations.length).toBe(0);
    });

    it("returns operations with valid synthetic block info", async () => {
      const cursor = "1753099264.927988000";
      const { items: ops } = await api.listOperations(MAINNET_TEST_ACCOUNTS.withTokens.accountId, {
        minHeight: 0,
        cursor,
        limit: 4,
        order: "asc",
      });

      const expectedSyntheticBlock = getSyntheticBlock(cursor);
      const blockHeights = ops.map(o => o.tx.block.height);

      expect(blockHeights.every(h => h >= expectedSyntheticBlock.blockHeight)).toBe(true);
    });

    it("returns operations for real account with tokens", async () => {
      const cursor = "1753099264.927988000";
      const { items: ops } = await api.listOperations(MAINNET_TEST_ACCOUNTS.withTokens.accountId, {
        minHeight: 0,
        cursor,
        limit: 100,
        order: "desc",
      });

      const memoTxHash = "WvMcFERtxRsGJqxqGVDYa6JR5PqLgFeJxiSVoimayaWra/AMEJMzC09LhdRLTZ/M";
      const operationWithMemo = ops.find(op => op.tx.hash === memoTxHash);
      const firstTokenAssociateOperations = ops.find(op => op.type === "ASSOCIATE_TOKEN");
      const firstSendTokenOperation = ops.find(o => o.type === "OUT" && o.asset.type !== "native");

      const hasReceiveHbarOperations = ops.some(o => o.type === "IN" && o.asset.type === "native");
      const hasSendHbarOperations = ops.some(op => op.type === "OUT" && op.asset.type === "native");
      const hasReceiveTokenOperations = ops.some(o => o.type === "IN" && o.asset.type !== "native");
      const hasSendTokenOperations = !!firstSendTokenOperation;
      const hasTokenAssociateOperations = !!firstTokenAssociateOperations;
      const hasFeesOperationForSendToken = ops.some(
        o =>
          o.type === "FEES" &&
          o.asset.type === "native" &&
          o.tx.hash === firstSendTokenOperation?.tx.hash,
      );

      expect(ops).toBeInstanceOf(Array);
      expect(ops.length).toBeGreaterThanOrEqual(2);
      expect(hasReceiveHbarOperations).toBe(true);
      expect(hasSendHbarOperations).toBe(true);
      expect(hasReceiveTokenOperations).toBe(true);
      expect(hasSendTokenOperations).toBe(true);
      expect(hasFeesOperationForSendToken).toBe(false);
      expect(hasTokenAssociateOperations).toBe(true);
      expect(operationWithMemo?.details).toMatchObject({
        pagingToken: expect.any(String),
        consensusTimestamp: expect.any(String),
        ledgerOpType: expect.any(String),
        memo: expect.any(String),
      });
      expect(firstTokenAssociateOperations?.details).toMatchObject({
        pagingToken: expect.any(String),
        consensusTimestamp: expect.any(String),
        ledgerOpType: expect.any(String),
        associatedTokenId: expect.any(String),
      });
      // every transfer operation should have a fees payer
      expect(ops.every(op => /^0\.0\.\d+$/.test(op.tx.feesPayer ?? ""))).toBe(true);
    });

    it("returns IN/OUT operations for mint and burn of amUSDC", async () => {
      const ownerAccountId = MAINNET_TEST_ACCOUNTS.withTokens.accountIdWithErc20;
      const { items: ops } = await api.listOperations(ownerAccountId, {
        minHeight: 0,
        limit: 10,
        cursor: "1749584382.000000000",
        order: "asc",
      });

      const zeroAddress = "0x0000000000000000000000000000000000000000";
      const mintTxHash = "1Ed3RfhFN0VQIyFfUrkljtsV9CzbzYNt3LJqqQyHsbiyKoVbJFhGkZwvqr3k0rYJ";
      const burnTxHash = "45Y5pSeY7ULMqJObvAtOow8AjamVNlG3XGbGLt5UrCP2HOdrQ4PzQfXqFlY4GDwd";

      const mintOperation = ops.find(op => op.tx.hash === mintTxHash);
      const burnOperation = ops.find(op => op.tx.hash === burnTxHash);
      const expectedAsset = {
        type: "erc20",
        assetReference: "0xb7687538c7f4cad022d5e97cc778d0b46457c5db",
        assetOwner: ownerAccountId,
      };

      expect(mintOperation).toMatchObject({
        type: "IN",
        recipients: [ownerAccountId],
        senders: [zeroAddress],
        asset: expectedAsset,
        tx: {
          fees: 30080000n,
          feesPayer: ownerAccountId,
        },
      });
      expect(burnOperation).toMatchObject({
        type: "OUT",
        recipients: [zeroAddress],
        senders: [ownerAccountId],
        asset: expectedAsset,
        tx: {
          fees: 52800000n,
          feesPayer: ownerAccountId,
        },
      });
    });

    it("returns staking operations with correct metadata", async () => {
      const cursor = "1762202113.000000000";
      const { items: ops } = await api.listOperations(
        MAINNET_TEST_ACCOUNTS.activeStaking.accountId,
        { minHeight: 0, cursor, limit: 30, order: "desc" },
      );

      const rewardOp = ops.find(op => op.type === "REWARD");
      const delegateOp = ops.find(op => op.type === "DELEGATE");
      const undelegateOp = ops.find(op => op.type === "UNDELEGATE");
      const redelegateOp = ops.find(op => op.type === "REDELEGATE");

      expect(delegateOp?.value).toBe(BigInt(0));
      expect(delegateOp?.tx.fees).toBeGreaterThan(BigInt(0));
      expect(delegateOp?.details).toMatchObject({
        previousStakingNodeId: null,
        targetStakingNodeId: expect.any(Number),
        stakedAmount: expect.any(BigInt),
      });
      expect(undelegateOp?.value).toBe(BigInt(0));
      expect(undelegateOp?.tx.fees).toBeGreaterThan(BigInt(0));
      expect(undelegateOp?.details).toMatchObject({
        previousStakingNodeId: expect.any(Number),
        targetStakingNodeId: null,
        stakedAmount: expect.any(BigInt),
      });
      expect(redelegateOp?.value).toBe(BigInt(0));
      expect(redelegateOp?.tx.fees).toBeGreaterThan(BigInt(0));
      expect(redelegateOp?.details).toMatchObject({
        previousStakingNodeId: expect.any(Number),
        targetStakingNodeId: expect.any(Number),
        stakedAmount: expect.any(BigInt),
      });
      expect(rewardOp?.value).toBeGreaterThan(BigInt(0));
      expect(rewardOp?.tx.fees).toBe(BigInt(0));
      expect(rewardOp?.tx.hash).not.toContain(STAKING_REWARD_HASH_SUFFIX);
      // every staking operation should have a fees payer
      expect(ops.every(op => /^0\.0\.\d+$/.test(op.tx.feesPayer ?? ""))).toBe(true);
    });

    it("returns valid senders and recipients for staking operations", async () => {
      const cursor = "1772617523.000000000";
      const { items: ops } = await api.listOperations(
        MAINNET_TEST_ACCOUNTS.withStakingHistory.accountId,
        { minHeight: 0, cursor, limit: 30, order: "desc" },
      );

      const delegateHash = "+07jwNyyEDuwngDgoW3sVgfTfDE5qn+HgPsbltlrUIW/n/LYpFSEwSQNOTu/8GLQ";
      const undelegateHash = "v0jXJwjKaypunqz91EuQDU2mz/ejSb3AvEJ5fgYkftl+DDT2mBlwB5bSRqXWyoth";
      const redelegateHash = "pm8vFWlcBEEPbB+pkZTUUxs0FfO2KyDtg0KNfOYnnba+rpHT63OIMhFKKNpfDokk";

      const delegateOp = ops.find(o => o.type !== "REWARD" && o.tx.hash === delegateHash);
      const undelegateOp = ops.find(o => o.type !== "REWARD" && o.tx.hash === undelegateHash);
      const redelegateOp = ops.find(o => o.type !== "REWARD" && o.tx.hash === redelegateHash);
      const rewardOp = ops.find(o => o.type === "REWARD");

      expect(delegateOp?.senders).toEqual([MAINNET_TEST_ACCOUNTS.withStakingHistory.accountId]);
      expect(delegateOp?.recipients).toEqual(["0.0.14"]);
      expect(undelegateOp?.senders).toEqual([MAINNET_TEST_ACCOUNTS.withStakingHistory.accountId]);
      expect(undelegateOp?.recipients).toEqual(["0.0.31"]);
      expect(redelegateOp?.senders).toEqual([MAINNET_TEST_ACCOUNTS.withStakingHistory.accountId]);
      expect(redelegateOp?.recipients).toEqual(["0.0.23"]);
      expect(rewardOp?.senders).toEqual([rewardPayerAddress]);
      expect(rewardOp?.recipients).toEqual([MAINNET_TEST_ACCOUNTS.withStakingHistory.accountId]);
    });

    it("returns valid stakedAmount, respecting uncommitted balance changes", async () => {
      const { items: ops } = await api.listOperations(
        MAINNET_TEST_ACCOUNTS.withQuickBalanceChanges.accountId,
        { minHeight: 0, limit: 10, order: "asc" },
      );

      const opDelegate1 = ops[2];
      const opOut1 = ops[3];
      const opUndelegate = ops[4];
      const opOut2 = ops[5];
      const opDelegate2 = ops[6];

      // starting point has known, hardcoded balance
      const expectedBalanceDelegate1 = BigInt(999834971);

      // after undelegate1 we expect stakedAmount to be initial balance reduced by:
      // 1. first DELEGATE fee
      // 2. first OUT value + fee
      const expectedBalanceUndelegate =
        expectedBalanceDelegate1 - opDelegate1.tx.fees - opOut1.value - opOut1.tx.fees;

      // after delegate2 we expect stakedAmount to be undelegate1 balance reduced by:
      // 1. first UNDELEGATE fee
      // 2. second OUT value + fee
      const expectedBalanceDelegate2 =
        expectedBalanceUndelegate - opUndelegate.tx.fees - opOut2.value - opOut2.tx.fees;

      expect(opOut1.type).toBe("OUT");
      expect(opOut2.type).toBe("OUT");
      expect(opDelegate1.type).toBe("DELEGATE");
      expect(opDelegate1.details?.stakedAmount).toBe(expectedBalanceDelegate1);
      expect(opUndelegate.type).toBe("UNDELEGATE");
      expect(opUndelegate.details?.stakedAmount).toBe(expectedBalanceUndelegate);
      expect(opDelegate2.type).toBe("DELEGATE");
      expect(opDelegate2.details?.stakedAmount).toBe(expectedBalanceDelegate2);
    });

    it.each(["desc", "asc"] as const)(
      "returns paginated operations for account with high activity (%s)",
      async order => {
        const minHeight = 0;
        const limit = 10;
        const initialCursor = order === "desc" ? "1762168437.643463899" : undefined;

        const { items: page1, next: pagingToken1 } = await api.listOperations(
          MAINNET_TEST_ACCOUNTS.withTokens.accountId,
          { minHeight, limit, order, ...(initialCursor ? { cursor: initialCursor } : {}) },
        );

        const { items: page2, next: pagingToken2 } = await api.listOperations(
          MAINNET_TEST_ACCOUNTS.withTokens.accountId,
          { minHeight, limit, order, ...(pagingToken1 ? { cursor: pagingToken1 } : {}) },
        );

        const firstPage1Timestamp = page1[0]?.tx?.date;
        const firstPage2Timestamp = page2[0]?.tx?.date;
        const lastPage1Timestamp = page1[page1.length - 1]?.tx?.date;
        const lastPage2Timestamp = page2[page2.length - 1]?.tx?.date;
        const page1Hashes = new Set(page1.map(op => op.tx.hash));
        const page2Hashes = new Set(page2.map(op => op.tx.hash));
        const hasOverlap = [...page2Hashes].some(hash => page1Hashes.has(hash));

        // NOTE: this won't be equal to limit, because single Hedera transaction can generate multiple operations
        expect(page1.length).toBeGreaterThanOrEqual(limit);
        expect(page2.length).toBeGreaterThanOrEqual(limit);
        expect(pagingToken1).not.toBeNull();
        expect(pagingToken2).not.toBeNull();
        expect(hasOverlap).toBe(false);
        expect(firstPage1Timestamp).toBeInstanceOf(Date);
        expect(firstPage2Timestamp).toBeInstanceOf(Date);
        expect(lastPage1Timestamp).toBeInstanceOf(Date);
        expect(lastPage2Timestamp).toBeInstanceOf(Date);
        expect(lastPage1Timestamp > firstPage2Timestamp).toBe(order === "desc");
        expect(firstPage1Timestamp < lastPage2Timestamp).toBe(order === "asc");
      },
    );
  });

  describe("getValidators", () => {
    it("returns validators with APY information", async () => {
      const result = await api.getValidators();

      expect(result.items.length).toBeGreaterThan(0);
      result.items.forEach(item => {
        expect(item).toMatchObject({
          address: expect.any(String),
          nodeId: expect.any(String),
          name: expect.any(String),
          description: expect.any(String),
          balance: expect.any(BigInt),
          apy: expect.any(Number),
        });
        expect(item.apy).toBeGreaterThanOrEqual(0);
        expect(item.apy).toBeLessThanOrEqual(1);
      });
    });
  });

  describe("getStakes", () => {
    it("returns empty stakes for pristine account", async () => {
      const stakes = await api.getStakes(MAINNET_TEST_ACCOUNTS.pristine.accountId);

      expect(stakes.items.length).toBe(0);
    });

    it("returns stake for delegated account", async () => {
      const stakes = await api.getStakes(MAINNET_TEST_ACCOUNTS.activeStaking.accountId);

      expect(stakes.items.length).toBeGreaterThan(0);
    });
  });

  describe("getRewards", () => {
    it("returns empty rewards for pristine account", async () => {
      const rewards = await api.getRewards(MAINNET_TEST_ACCOUNTS.pristine.accountId);

      expect(rewards.items.length).toBe(0);
    });

    it("returns rewards for delegated account", async () => {
      const rewards = await api.getRewards(MAINNET_TEST_ACCOUNTS.activeStaking.accountId);

      expect(rewards.items.length).toBeGreaterThan(0);
    });
  });
});
