import { setupCalClientStore } from "@ledgerhq/cryptoassets/cal-client/test-helpers";
import { getCryptoAssetsStore } from "@ledgerhq/cryptoassets/state";
import { encodeTokenAccountId } from "@ledgerhq/ledger-wallet-framework/account";
import BigNumber from "bignumber.js";
import { HEDERA_OPERATION_TYPES, HEDERA_TRANSACTION_MODES } from "../constants";
import { estimateFees } from "../logic/estimateFees";
import { apiClient } from "../network/api";
import { getMockedAccount, getMockedTokenAccount } from "../test/fixtures/account.fixture";
import {
  getMockedHTSTokenCurrency,
  getTokenCurrencyFromCAL,
  getTokenCurrencyFromCALByType,
} from "../test/fixtures/currency.fixture";
import { getMockedMirrorToken } from "../test/fixtures/mirror.fixture";
import { getMockedOperation } from "../test/fixtures/operation.fixture";
import { getMockedThirdwebTransaction } from "../test/fixtures/thirdweb.fixture";
import { getMockedTransaction } from "../test/fixtures/transaction.fixture";
import type { EstimateFeesResult } from "../types";
import { calculateAmount, getSubAccounts, integrateERC20Operations } from "./utils";

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

  describe("calculateAmount", () => {
    let estimatedFees: Record<"crypto" | "associate", EstimateFeesResult>;

    beforeAll(async () => {
      const mockedAccount = getMockedAccount();
      const [crypto, associate] = await Promise.all([
        estimateFees({
          currency: mockedAccount.currency,
          operationType: HEDERA_OPERATION_TYPES.CryptoTransfer,
        }),
        estimateFees({
          currency: mockedAccount.currency,
          operationType: HEDERA_OPERATION_TYPES.TokenAssociate,
        }),
      ]);

      estimatedFees = { crypto, associate };
    });

    it("HBAR transfer, useAllAmount = true", async () => {
      const mockedAccount = getMockedAccount();
      const mockedTransaction = getMockedTransaction({ useAllAmount: true });

      const amount = mockedAccount.balance.minus(estimatedFees.crypto.tinybars);
      const totalSpent = amount.plus(estimatedFees.crypto.tinybars);

      const result = await calculateAmount({
        account: mockedAccount,
        transaction: mockedTransaction,
      });

      expect(result).toEqual({ amount, totalSpent });
    });

    it("HBAR transfer, useAllAmount = false", async () => {
      const mockedAccount = getMockedAccount();
      const mockedTransaction = getMockedTransaction({
        useAllAmount: false,
        amount: new BigNumber(1000000),
      });

      const amount = mockedTransaction.amount;
      const totalSpent = amount.plus(estimatedFees.crypto.tinybars);

      const result = await calculateAmount({
        account: mockedAccount,
        transaction: mockedTransaction,
      });

      expect(result).toEqual({ amount, totalSpent });
    });

    it("token transfer, useAllAmount = true", async () => {
      const mockedTokenCurrency = getMockedHTSTokenCurrency();
      const mockedTokenAccount = getMockedTokenAccount(mockedTokenCurrency);
      const mockedAccount = getMockedAccount({ subAccounts: [mockedTokenAccount] });
      const mockedTransaction = getMockedTransaction({
        useAllAmount: true,
        subAccountId: mockedTokenAccount.id,
      });

      const amount = mockedTokenAccount.balance;
      const totalSpent = amount;

      const result = await calculateAmount({
        account: mockedAccount,
        transaction: mockedTransaction,
      });

      expect(result).toEqual({ amount, totalSpent });
    });

    it("token transfer, useAllAmount = false", async () => {
      const mockedTokenCurrency = getMockedHTSTokenCurrency();
      const mockedTokenAccount = getMockedTokenAccount(mockedTokenCurrency);
      const mockedAccount = getMockedAccount({ subAccounts: [mockedTokenAccount] });
      const mockedTransaction = getMockedTransaction({
        useAllAmount: false,
        amount: new BigNumber(1),
        subAccountId: mockedTokenAccount.id,
      });

      const amount = mockedTransaction.amount;
      const totalSpent = amount;

      const result = await calculateAmount({
        account: mockedAccount,
        transaction: mockedTransaction,
      });

      expect(result).toEqual({ amount, totalSpent });
    });

    it("token associate operation uses TokenAssociate fee", async () => {
      const mockedTokenCurrency = getMockedHTSTokenCurrency();
      const mockedTokenAccount = getMockedTokenAccount(mockedTokenCurrency);
      const mockedAccount = getMockedAccount({ subAccounts: [mockedTokenAccount] });
      const mockedTransaction = getMockedTransaction({
        useAllAmount: false,
        amount: new BigNumber(1),
        mode: HEDERA_TRANSACTION_MODES.TokenAssociate,
        properties: {
          token: mockedTokenCurrency,
        },
      });

      const amount = mockedTransaction.amount;
      const totalSpent = amount.plus(estimatedFees.associate.tinybars);

      const result = await calculateAmount({
        account: mockedAccount,
        transaction: mockedTransaction,
      });

      expect(result).toEqual({ amount, totalSpent });
    });
  });

  describe("getSubAccounts", () => {
    it("returns sub account based on operations and mirror tokens", async () => {
      const firstTokenCurrencyFromCAL = getTokenCurrencyFromCAL(0);
      const secondTokenCurrencyFromCAL = getTokenCurrencyFromCAL(1);
      const mockedAccount = getMockedAccount();
      const mockedMirrorToken1 = getMockedMirrorToken({
        token_id: firstTokenCurrencyFromCAL.contractAddress,
        balance: 10,
      });
      const mockedMirrorToken2 = getMockedMirrorToken({
        token_id: secondTokenCurrencyFromCAL.contractAddress,
        balance: 0,
      });

      // Fetch actual tokens from CAL to get the real format
      const firstTokenFromCAL = await getCryptoAssetsStore().findTokenByAddressInCurrency(
        firstTokenCurrencyFromCAL.contractAddress,
        "hedera",
      );
      const secondTokenFromCAL = await getCryptoAssetsStore().findTokenByAddressInCurrency(
        secondTokenCurrencyFromCAL.contractAddress,
        "hedera",
      );

      if (!firstTokenFromCAL || !secondTokenFromCAL) {
        throw new Error("Tokens not found in CAL");
      }

      const mockedOperation1 = getMockedOperation({
        accountId: encodeTokenAccountId(mockedAccount.id, firstTokenFromCAL),
      });
      const mockedOperation2 = getMockedOperation({
        accountId: encodeTokenAccountId(mockedAccount.id, secondTokenFromCAL),
      });

      const result = await getSubAccounts({
        ledgerAccountId: mockedAccount.id,
        latestTokenOperations: [mockedOperation1, mockedOperation2],
        mirrorTokens: [mockedMirrorToken1, mockedMirrorToken2],
        erc20Tokens: [],
      });
      const uniqueSubAccountIds = new Set(result.map(sa => sa.id));

      expect(uniqueSubAccountIds.size).toBe(result.length);
      expect(result).toHaveLength(2);
      expect(result).toMatchObject([
        {
          token: firstTokenCurrencyFromCAL,
          balance: new BigNumber(10),
          operations: [mockedOperation1],
        },
        {
          token: secondTokenCurrencyFromCAL,
          balance: new BigNumber(0),
          operations: [mockedOperation2],
        },
      ]);
    });

    it("ignores operation if token is not listed in CAL", async () => {
      const mockedTokenCurrency = getMockedHTSTokenCurrency();
      const mockedAccount = getMockedAccount();
      const mockedOperation = getMockedOperation({
        accountId: encodeTokenAccountId(mockedAccount.id, mockedTokenCurrency),
      });

      const result = await getSubAccounts({
        ledgerAccountId: mockedAccount.id,
        latestTokenOperations: [mockedOperation],
        mirrorTokens: [],
        erc20Tokens: [],
      });

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

    it("returns sub account for mirror token with no operations yet (e.g. right after association)", async () => {
      const tokenCurrencyFromCAL = getTokenCurrencyFromCAL(0);
      const mockedAccount = getMockedAccount();
      const mockedTokenHTS = getMockedMirrorToken({
        token_id: tokenCurrencyFromCAL.contractAddress,
        balance: 42,
      });

      const result = await getSubAccounts({
        ledgerAccountId: mockedAccount.id,
        latestTokenOperations: [],
        mirrorTokens: [mockedTokenHTS],
        erc20Tokens: [],
      });

      expect(result).toMatchObject([
        {
          token: tokenCurrencyFromCAL,
          operations: [],
          balance: new BigNumber(42),
        },
      ]);
    });

    it("returns sub account for erc20 token with no operations yet", async () => {
      const tokenCurrencyFromCAL = getTokenCurrencyFromCALByType("erc20");
      const mockedAccount = getMockedAccount();

      const result = await getSubAccounts({
        ledgerAccountId: mockedAccount.id,
        latestTokenOperations: [],
        mirrorTokens: [],
        erc20Tokens: [{ balance: new BigNumber(42), token: tokenCurrencyFromCAL }],
      });

      expect(result).toMatchObject([
        {
          token: tokenCurrencyFromCAL,
          operations: [],
          balance: new BigNumber(42),
        },
      ]);
    });
  });

  describe("integrateERC20Operations", () => {
    const address = "0.0.12345";
    const evmAddress = "0x0000000000000000000000000000000000003039";
    const ledgerAccountId = `js:2:hedera:${address}:`;
    const tokenCurrency = getTokenCurrencyFromCALByType("erc20");

    afterEach(() => {
      jest.restoreAllMocks();
    });

    it("creates new operation for erc20 in transfer", async () => {
      const mockGetContractCallResult = jest.spyOn(apiClient, "getContractCallResult");
      const mockFindTransactionByContractCall = jest.spyOn(
        apiClient,
        "findTransactionByContractCall",
      );

      const incomingTxConsensusTimestamp = `1705836000.000000000`;
      const incomingTxHash = "incoming_erc20";
      const incomingTxValue = "3000000";
      const incomingTxFrom = "0xSENDER";
      const incomingTxTo = evmAddress;
      const incomingERC20Transaction = getMockedThirdwebTransaction({
        transactionHash: incomingTxHash,
        address: tokenCurrency.contractAddress,
        blockHash: "0xINCOMING_BLOCK",
        blockNumber: 12345,
        decoded: {
          name: "Transfer",
          signature: "Transfer(address,address,uint256)",
          params: {
            from: incomingTxFrom,
            to: incomingTxTo,
            value: incomingTxValue,
          },
        },
      });
      const oldMirrorOperations = [
        getMockedOperation({
          hash: "normal_tx",
          type: "IN",
          date: new Date("2024-01-20T10:00:00Z"),
        }),
      ];

      mockGetContractCallResult.mockResolvedValue({
        timestamp: incomingTxConsensusTimestamp,
        contract_id: tokenCurrency.contractAddress,
      } as any);

      mockFindTransactionByContractCall.mockResolvedValue({
        transaction_hash: incomingTxHash,
        consensus_timestamp: incomingTxConsensusTimestamp,
      } as any);

      const { updatedOperations, newERC20TokenOperations } = await integrateERC20Operations({
        ledgerAccountId,
        address,
        allOperations: oldMirrorOperations,
        latestERC20Transactions: [incomingERC20Transaction],
        pendingOperationHashes: new Set(),
        erc20OperationHashes: new Set(),
      });

      const incomingOp = updatedOperations.find(op => op.hash === incomingTxHash);

      expect(incomingOp).toMatchObject({
        type: "NONE",
        hash: incomingTxHash,
        blockHash: incomingERC20Transaction.blockHash,
      });
      expect(incomingOp?.subOperations).toMatchObject([
        {
          type: "IN",
          hash: incomingTxHash,
          blockHash: incomingERC20Transaction.blockHash,
          standard: "erc20",
          value: new BigNumber(incomingTxValue),
          senders: [incomingTxFrom],
          recipients: [address],
        },
      ]);
      expect(newERC20TokenOperations).toMatchObject([incomingOp?.subOperations?.[0]]);
      expect(updatedOperations).toHaveLength(oldMirrorOperations.length + 1);
    });

    it("creates new operation for erc20 out transfer (not made by user)", async () => {
      const mockGetContractCallResult = jest.spyOn(apiClient, "getContractCallResult");
      const mockFindTransactionByContractCall = jest.spyOn(
        apiClient,
        "findTransactionByContractCall",
      );

      const allowanceTxConsensusTimestamp = "1705922400.000000000";
      const allowanceTxHash = "transfer_by_allowance";
      const allowanceTxValue = "2000000";
      const allowanceTxFrom = evmAddress;
      const allowanceTxTo = "0xRECIPIENT";

      const oldMirrorOperations = [
        getMockedOperation({
          hash: "normal_tx",
          type: "OUT",
          date: new Date("2024-01-20T10:00:00Z"),
        }),
      ];

      const allowanceERC20Transaction = getMockedThirdwebTransaction({
        transactionHash: allowanceTxHash,
        address: tokenCurrency.contractAddress,
        blockHash: "0xALLOWANCE_BLOCK",
        blockNumber: 12346,
        decoded: {
          name: "Transfer",
          signature: "Transfer(address,address,uint256)",
          params: {
            from: allowanceTxFrom,
            to: allowanceTxTo,
            value: allowanceTxValue,
          },
        },
      });

      mockGetContractCallResult.mockResolvedValue({
        timestamp: allowanceTxConsensusTimestamp,
        contract_id: tokenCurrency.contractAddress,
      } as any);

      mockFindTransactionByContractCall.mockResolvedValue({
        transaction_hash: allowanceTxHash,
        consensus_timestamp: allowanceTxConsensusTimestamp,
      } as any);

      const { updatedOperations, newERC20TokenOperations } = await integrateERC20Operations({
        ledgerAccountId,
        address,
        allOperations: oldMirrorOperations,
        latestERC20Transactions: [allowanceERC20Transaction],
        pendingOperationHashes: new Set(),
        erc20OperationHashes: new Set(),
      });

      const allowanceOp = updatedOperations.find(op => op.hash === allowanceTxHash);

      expect(allowanceOp).toMatchObject({
        type: "FEES",
        hash: allowanceTxHash,
        blockHash: allowanceERC20Transaction.blockHash,
        standard: "erc20",
      });
      expect(allowanceOp?.subOperations).toMatchObject([
        {
          type: "OUT",
          hash: allowanceTxHash,
          blockHash: allowanceERC20Transaction.blockHash,
          standard: "erc20",
          value: new BigNumber(allowanceTxValue),
          senders: [address],
          recipients: [allowanceTxTo],
        },
      ]);
      expect(newERC20TokenOperations).toMatchObject([allowanceOp?.subOperations?.[0]]);
      expect(updatedOperations).toHaveLength(oldMirrorOperations.length + 1);
    });

    it("avoids duplicated CONTRACT_CALL operation if confirmed erc20 operation exists", async () => {
      const mockGetContractCallResult = jest.spyOn(apiClient, "getContractCallResult");
      const mockFindTransactionByContractCall = jest.spyOn(
        apiClient,
        "findTransactionByContractCall",
      );

      const duplicateTxConsensusTimestamp = "1705836000.000000000";
      const duplicateTxHash = "duplicate_tx";

      const operationsWithDuplicate = [
        getMockedOperation({
          hash: duplicateTxHash,
          type: "FEES",
          standard: "erc20",
          date: new Date("2024-01-20T10:00:00Z"),
          blockHash: "0xBLOCK",
          subOperations: [
            getMockedOperation({
              type: "OUT",
              standard: "erc20",
              hash: duplicateTxHash,
              accountId: encodeTokenAccountId(ledgerAccountId, tokenCurrency),
            }),
          ],
        }),
        getMockedOperation({
          hash: duplicateTxHash,
          type: "CONTRACT_CALL",
          date: new Date("2024-01-20T10:00:00Z"),
        }),
        getMockedOperation({
          hash: "unique_tx",
          type: "OUT",
          date: new Date("2024-01-19T10:00:00Z"),
        }),
      ];

      const duplicateERC20Transaction = getMockedThirdwebTransaction({
        transactionHash: duplicateTxHash,
        address: tokenCurrency.contractAddress,
        blockHash: "0xBLOCK",
        decoded: {
          name: "Transfer",
          signature: "Transfer(address,address,uint256)",
          params: {
            from: evmAddress,
            to: "0xRECIPIENT",
            value: "1000000",
          },
        },
      });

      mockGetContractCallResult.mockResolvedValue({
        timestamp: duplicateTxConsensusTimestamp,
        contract_id: tokenCurrency.contractAddress,
      } as any);

      mockFindTransactionByContractCall.mockResolvedValue({
        transaction_hash: duplicateTxHash,
        consensus_timestamp: duplicateTxConsensusTimestamp,
      } as any);

      const { updatedOperations } = await integrateERC20Operations({
        ledgerAccountId,
        address,
        allOperations: operationsWithDuplicate,
        latestERC20Transactions: [duplicateERC20Transaction],
        pendingOperationHashes: new Set(),
        erc20OperationHashes: new Set([duplicateTxHash]),
      });

      const duplicatedContractCalls = updatedOperations.filter(
        op => op.type === "CONTRACT_CALL" && op.hash === duplicateTxHash,
      );
      const feesOps = updatedOperations.filter(
        op => op.type === "FEES" && op.hash === duplicateTxHash,
      );

      expect(updatedOperations).toHaveLength(2);
      expect(duplicatedContractCalls).toEqual([]);
      expect(feesOps).toHaveLength(1);
      expect(feesOps).toMatchObject([{ blockHash: "0xBLOCK" }]);
    });

    it("avoids duplicated CONTRACT_CALL operation if erc20 operation is pending", async () => {
      const pendingTxHash = "pending_erc20";

      const operationsWithPending = [
        getMockedOperation({
          hash: pendingTxHash,
          type: "CONTRACT_CALL",
          date: new Date("2024-01-20T10:00:00Z"),
        }),
        getMockedOperation({
          hash: "confirmed_tx",
          type: "OUT",
          date: new Date("2024-01-19T10:00:00Z"),
        }),
      ];

      const { updatedOperations } = await integrateERC20Operations({
        ledgerAccountId,
        address,
        allOperations: operationsWithPending,
        latestERC20Transactions: [],
        pendingOperationHashes: new Set([pendingTxHash]),
        erc20OperationHashes: new Set(),
      });

      const pendingOp = updatedOperations.find(op => op.hash === pendingTxHash);

      expect(pendingOp).toBeUndefined();
      expect(updatedOperations).toHaveLength(1);
      expect(updatedOperations).toMatchObject([{ hash: "confirmed_tx" }]);
    });

    /**
     * Timeline:
     * - Tuesday: Normal transactions
     * - Wednesday: ERC20 transfer (Mirror + Thirdweb in sync)
     * - Thursday: Normal transaction
     * - Friday: ERC20 transfer (Thirdweb stuck - no event)
     * - Saturday: Normal transaction
     *
     * SYNC 1 (Friday):
     * - Mirror Node shows CONTRACT_CALL without blockHash
     * - Thirdweb has no event yet (indexer stuck)
     * - Operation remains as CONTRACT_CALL (not enriched)
     *
     * SYNC 2 (Saturday):
     * - Thirdweb catches up and returns Friday's event
     * - CONTRACT_CALL should get patched to FEES with ERC20 sub-operation
     */
    it("handles delayed thirdweb indexer", async () => {
      const mockGetContractCallResult = jest.spyOn(apiClient, "getContractCallResult");
      const mockFindTransactionByContractCall = jest.spyOn(
        apiClient,
        "findTransactionByContractCall",
      );

      const fridayTxConsensusTimestamp = `1705678200.000000000`;

      // sync 1 from Friday: thirdweb hasn't indexed yet
      const fridaySyncOperations = [
        getMockedOperation({
          hash: "saturday_tx",
          type: "OUT",
          date: new Date("2024-01-20T10:00:00Z"),
        }),
        getMockedOperation({
          hash: "friday_erc20",
          type: "CONTRACT_CALL",
          date: new Date("2024-01-19T15:30:00Z"),
        }),
        getMockedOperation({
          hash: "thursday_tx",
          type: "OUT",
          date: new Date("2024-01-18T12:00:00Z"),
        }),
        getMockedOperation({
          hash: "wednesday_erc20",
          type: "FEES",
          date: new Date("2024-01-17T09:00:00Z"),
          standard: "erc20",
          blockHash: "0xWEDNESDAY_BLOCK",
          subOperations: [
            getMockedOperation({
              type: "OUT",
              standard: "erc20",
              hash: "wednesday_erc20",
              accountId: encodeTokenAccountId(ledgerAccountId, tokenCurrency),
            }),
          ],
        }),
        getMockedOperation({
          hash: "tuesday_tx",
          type: "OUT",
          date: new Date("2024-01-16T08:00:00Z"),
        }),
      ];

      // thirdweb catches up with Friday's event
      const lateERC20Transaction = getMockedThirdwebTransaction({
        transactionHash: "friday_erc20",
        address: tokenCurrency.contractAddress,
        decoded: {
          name: "Transfer",
          signature: "Transfer(address,address,uint256)",
          params: {
            from: evmAddress,
            to: "0xRECIPIENT",
            value: "5000000",
          },
        },
      });

      mockGetContractCallResult.mockResolvedValue({
        timestamp: fridayTxConsensusTimestamp,
        contract_id: tokenCurrency.contractAddress,
      } as any);

      mockFindTransactionByContractCall.mockResolvedValue({
        transaction_hash: lateERC20Transaction.transactionHash,
        consensus_timestamp: fridayTxConsensusTimestamp,
      } as any);

      const { updatedOperations, newERC20TokenOperations } = await integrateERC20Operations({
        ledgerAccountId,
        address,
        allOperations: fridaySyncOperations,
        latestERC20Transactions: [lateERC20Transaction],
        pendingOperationHashes: new Set(),
        erc20OperationHashes: new Set(["wednesday_erc20"]),
      });

      // check if friday operation got patched
      const wednesdayOp = updatedOperations.find(op => op.hash === "wednesday_erc20");
      const fridayOp = updatedOperations.find(
        op => op.hash === lateERC20Transaction.transactionHash,
      );

      expect(fridayOp).toMatchObject({
        type: "FEES",
        standard: "erc20",
        hash: lateERC20Transaction.transactionHash,
        subOperations: [
          {
            type: "OUT",
            standard: "erc20",
          },
        ],
      });
      expect(newERC20TokenOperations).toMatchObject([
        {
          type: "OUT",
          hash: lateERC20Transaction.transactionHash,
          accountId: encodeTokenAccountId(ledgerAccountId, tokenCurrency),
        },
      ]);
      expect(wednesdayOp).toMatchObject({
        type: "FEES",
        blockHash: "0xWEDNESDAY_BLOCK",
      });
      expect(updatedOperations[0]).toMatchObject({ hash: "saturday_tx" });
      expect(updatedOperations.at(-1)).toMatchObject({ hash: "tuesday_tx" });
      expect(updatedOperations).toHaveLength(fridaySyncOperations.length);
    });
  });
});
