import { LedgerAPI4xx } from "@ledgerhq/errors";
import network from "@ledgerhq/live-network";
import BigNumber from "bignumber.js";
import { resolveConfig } from "../logic/utils";
import { getMockedConfig } from "../test/fixtures/config.fixture";
import { getMockResponse } from "../test/fixtures/network.fixture";
import type {
  HederaMirrorContractCallResult,
  HederaMirrorNetworkFees,
  HederaMirrorTransaction,
} from "../types";
import { apiClient } from "./api";

jest.mock("@ledgerhq/live-network");
jest.mock("../logic/utils", () => ({
  ...jest.requireActual("../logic/utils"),
  resolveConfig: jest.fn(),
}));

const mockedNetwork = jest.mocked(network);
const mockedResolveConfig = jest.mocked(resolveConfig);

describe("apiClient", () => {
  const mockAddress = "0.0.1234";
  const mockConfig = getMockedConfig();

  beforeEach(() => {
    jest.resetAllMocks();

    mockedResolveConfig.mockReturnValue(mockConfig);
  });

  describe("getAccountsForPublicKey", () => {
    it("should call the correct endpoint and return accounts", async () => {
      const mockAccounts = [{ account: "0.0.1234" }, { account: "0.0.5678" }];
      mockedNetwork.mockResolvedValueOnce(getMockResponse({ accounts: mockAccounts }));

      const result = await apiClient.getAccountsForPublicKey({
        configOrCurrencyId: mockConfig,
        publicKey: "abc123",
      });
      const requestUrl = mockedNetwork.mock.calls[0][0].url;

      expect(result).toEqual(mockAccounts);
      expect(requestUrl).toContain("/api/v1/accounts");
      expect(requestUrl).toContain("account.publicKey=abc123");
      expect(requestUrl).toContain("balance=true");
      expect(requestUrl).toContain("limit=100");
      expect(mockedNetwork).toHaveBeenCalledTimes(1);
    });

    it("should return empty array on LedgerAPI4xx error", async () => {
      mockedNetwork.mockRejectedValueOnce(new LedgerAPI4xx());

      const result = await apiClient.getAccountsForPublicKey({
        configOrCurrencyId: mockConfig,
        publicKey: "abc123",
      });

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

    it("should rethrow non-LedgerAPI4xx errors", async () => {
      const error = new Error("network error");
      mockedNetwork.mockRejectedValueOnce(error);

      await expect(
        apiClient.getAccountsForPublicKey({
          configOrCurrencyId: mockConfig,
          publicKey: "abc123",
        }),
      ).rejects.toThrow(error);
    });
  });

  describe("getAccountTransactions", () => {
    it("should include 'account.id', 'limit=100' and 'order=desc' query params", async () => {
      mockedNetwork.mockResolvedValueOnce(
        getMockResponse({ transactions: [], links: { next: null } }),
      );

      await apiClient.getAccountTransactions({
        configOrCurrencyId: mockConfig,
        address: "0.0.1234",
        pagingToken: null,
        fetchAllPages: true,
      });

      const requestUrl = mockedNetwork.mock.calls[0][0].url;
      expect(requestUrl).toContain("account.id=0.0.1234");
      expect(requestUrl).toContain("limit=100");
      expect(requestUrl).toContain("order=desc");
    });

    it("should keep fetching if fetchAllPages is set and links.next is present", async () => {
      mockedNetwork
        .mockResolvedValueOnce(
          getMockResponse({
            transactions: [{ consensus_timestamp: "1" }],
            links: { next: "/next-1" },
          }),
        )
        .mockResolvedValueOnce(
          getMockResponse({
            transactions: [],
            links: { next: "/next-2" },
          }),
        )
        .mockResolvedValueOnce(
          getMockResponse({
            transactions: [{ consensus_timestamp: "3" }],
            links: { next: "/next-3" },
          }),
        )
        .mockResolvedValueOnce(
          getMockResponse({
            transactions: [{ consensus_timestamp: "4" }],
            links: { next: "/next-4" },
          }),
        )
        .mockResolvedValueOnce(
          getMockResponse({
            transactions: [],
            links: { next: null },
          }),
        );

      const result = await apiClient.getAccountTransactions({
        configOrCurrencyId: mockConfig,
        address: "0.0.1234",
        pagingToken: null,
        fetchAllPages: true,
      });

      expect(result.transactions.map(tx => tx.consensus_timestamp)).toEqual(["1", "3", "4"]);
      expect(result.nextCursor).toBeNull();
      expect(mockedNetwork).toHaveBeenCalledTimes(5);
    });

    it("should paginate if fetchAllPages is not set", async () => {
      mockedNetwork
        .mockResolvedValueOnce(
          getMockResponse({
            transactions: [{ consensus_timestamp: "1" }],
            links: { next: "/next-1" },
          }),
        )
        .mockResolvedValueOnce(
          getMockResponse({
            transactions: [],
            links: { next: "/next-2" },
          }),
        )
        .mockResolvedValueOnce(
          getMockResponse({
            transactions: [{ consensus_timestamp: "3" }],
            links: { next: "/next-3" },
          }),
        )
        .mockResolvedValueOnce(
          getMockResponse({
            transactions: [{ consensus_timestamp: "4" }],
            links: { next: "/next-4" },
          }),
        )
        .mockResolvedValueOnce(
          getMockResponse({
            transactions: [],
            links: { next: null },
          }),
        );

      const result = await apiClient.getAccountTransactions({
        configOrCurrencyId: mockConfig,
        address: "0.0.1234",
        pagingToken: null,
        limit: 2,
        fetchAllPages: false,
      });

      expect(result.transactions.map(tx => tx.consensus_timestamp)).toEqual(["1", "3"]);
      expect(result.nextCursor).toBe("3");
      expect(mockedNetwork).toHaveBeenCalledTimes(3);
    });
  });

  describe("getAccount", () => {
    it("should call the correct endpoint and return account data", async () => {
      mockedNetwork.mockResolvedValueOnce(
        getMockResponse({
          account: mockAddress,
          max_automatic_token_associations: 0,
          balance: {
            balance: 1000,
            timestamp: "1749047764.000113442",
            tokens: [],
          },
        }),
      );

      const result = await apiClient.getAccount({
        configOrCurrencyId: mockConfig,
        address: mockAddress,
      });
      const requestUrl = mockedNetwork.mock.calls[0][0].url;

      expect(result.account).toEqual(mockAddress);
      expect(requestUrl).toContain(`/api/v1/accounts/${mockAddress}?transactions=false`);
      expect(mockedNetwork).toHaveBeenCalledTimes(1);
    });

    it("supports timestamp filter", async () => {
      const mockAccount = { account: mockAddress, staked_node_id: null };
      const timestamp = "lt:1762202064.065172388";

      (network as jest.Mock).mockResolvedValueOnce({ data: mockAccount });

      const result = await apiClient.getAccount({
        configOrCurrencyId: mockConfig,
        address: mockAddress,
        timestamp,
      });
      const requestUrl = mockedNetwork.mock.calls[0][0].url;

      expect(mockedNetwork).toHaveBeenCalledTimes(1);
      expect(result).toEqual(mockAccount);
      expect(requestUrl).toContain(
        `/api/v1/accounts/${mockAddress}?transactions=false&timestamp=${encodeURIComponent(timestamp)}`,
      );
    });
  });

  describe("getAccountTokens", () => {
    it("should return all tokens if only one page is needed", async () => {
      mockedNetwork.mockResolvedValueOnce(
        getMockResponse({
          tokens: [
            { token_id: "0.0.1001", balance: 10 },
            { token_id: "0.0.1002", balance: 20 },
          ],
          links: { next: null },
        }),
      );

      const result = await apiClient.getAccountTokens({
        configOrCurrencyId: mockConfig,
        address: "0.0.1234",
      });
      const requestUrl = mockedNetwork.mock.calls[0][0].url;

      expect(result.map(t => t.token_id)).toEqual(["0.0.1001", "0.0.1002"]);
      expect(requestUrl).toContain("/api/v1/accounts/0.0.1234/tokens");
      expect(requestUrl).toContain("limit=100");
      expect(mockedNetwork).toHaveBeenCalledTimes(1);
    });

    it("should keep fetching if links.next is present and new tokens are returned", async () => {
      mockedNetwork
        .mockResolvedValueOnce(
          getMockResponse({
            tokens: [{ token_id: "0.0.1001", balance: 10 }],
            links: { next: "/next-1" },
          }),
        )
        .mockResolvedValueOnce(
          getMockResponse({
            tokens: [{ token_id: "0.0.1002", balance: 20 }],
            links: { next: null },
          }),
        );

      const result = await apiClient.getAccountTokens({
        configOrCurrencyId: mockConfig,
        address: "0.0.1234",
      });

      expect(result.map(t => t.token_id)).toEqual(["0.0.1001", "0.0.1002"]);
      expect(mockedNetwork).toHaveBeenCalledTimes(2);
    });
  });

  describe("getNetworkFees", () => {
    it("should call the correct endpoint and return network fees", async () => {
      const mockedResults: HederaMirrorNetworkFees = {
        fees: [{ gas: 39, transaction_type: "ContractCall" }],
        timestamp: "1758733200.632122898",
      };

      mockedNetwork.mockResolvedValueOnce(getMockResponse(mockedResults));

      const result = await apiClient.getNetworkFees({ configOrCurrencyId: mockConfig });
      const requestUrl = mockedNetwork.mock.calls[0][0].url;

      expect(result).toEqual(mockedResults);
      expect(requestUrl).toContain("/api/v1/network/fees");
      expect(mockedNetwork).toHaveBeenCalledTimes(1);
    });
  });

  describe("getLatestBlock", () => {
    beforeEach(() => {
      jest.clearAllMocks();
    });

    it("should return latest block", async () => {
      const latestBlock = {
        timestamp: { from: "1758733199.000000000", to: "1758733200.632122898" },
      };

      mockedNetwork.mockResolvedValueOnce(
        getMockResponse({
          blocks: [latestBlock],
          links: { next: null },
        }),
      );

      const result = await apiClient.getLatestBlock({
        configOrCurrencyId: mockConfig,
      });

      expect(result).toEqual(latestBlock);
      expect(mockedNetwork).toHaveBeenCalledTimes(1);
      expect(mockedNetwork.mock.calls[0][0].url).toContain("/api/v1/blocks");
      expect(mockedNetwork.mock.calls[0][0].url).toContain("limit=1");
      expect(mockedNetwork.mock.calls[0][0].url).toContain("order=desc");
    });

    it("should throw when no blocks are returned", async () => {
      mockedNetwork.mockResolvedValueOnce(
        getMockResponse({
          blocks: [],
          links: { next: null },
        }),
      );

      await expect(apiClient.getLatestBlock({ configOrCurrencyId: mockConfig })).rejects.toThrow(
        "No blocks found on the Hedera network",
      );
      expect(mockedNetwork).toHaveBeenCalledTimes(1);
    });
  });

  describe("getContractCallResult", () => {
    beforeEach(() => {
      jest.clearAllMocks();
    });

    it("should call the correct endpoint and return results for contract call", async () => {
      const mockedResults: HederaMirrorContractCallResult = {
        contract_id: "0.0.4321",
        block_gas_used: 100,
        block_hash: "0xabc",
        gas_consumed: 200,
        gas_limit: 10000,
        gas_used: 150,
        timestamp: "xxxxxxxxx",
      };

      mockedNetwork.mockResolvedValueOnce(
        getMockResponse({
          contract_id: "0.0.4321",
          block_gas_used: 100,
          block_hash: "0xabc",
          gas_consumed: 200,
          gas_limit: 10000,
          gas_used: 150,
          timestamp: "xxxxxxxxx",
        }),
      );

      const result = await apiClient.getContractCallResult({
        configOrCurrencyId: mockConfig,
        transactionHash:
          "0xa9059cbb000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000186a0",
      });
      const requestUrl = mockedNetwork.mock.calls[0][0].url;

      expect(result).toEqual(mockedResults);
      expect(requestUrl).toContain("/api/v1/contracts/results");
      expect(mockedNetwork).toHaveBeenCalledTimes(1);
    });
  });

  describe("findTransactionByContractCallV2", () => {
    it("should call the correct endpoint and return transaction details", async () => {
      const mockConsensusTimestamp = "1758733200.632122898";
      const mockPayerAddress = "0.0.1234";
      const mockedResults: HederaMirrorTransaction = {
        transfers: [],
        token_transfers: [],
        staking_reward_transfers: [],
        charged_tx_fee: 100,
        transaction_hash: "",
        result: "",
        consensus_timestamp: mockConsensusTimestamp,
        entity_id: "0.0.1",
        transaction_id: `${mockPayerAddress}-${mockConsensusTimestamp}`,
        name: "CONTRACTCALL",
        node: null,
        nonce: 0,
        parent_consensus_timestamp: null,
      };

      mockedNetwork.mockResolvedValueOnce(
        getMockResponse({
          transactions: [mockedResults],
        }),
      );

      const result = await apiClient.findTransactionByContractCallV2({
        configOrCurrencyId: mockConfig,
        timestamp: mockConsensusTimestamp,
        payerAddress: mockPayerAddress,
      });
      const requestUrl = mockedNetwork.mock.calls[0][0].url;

      expect(result).toEqual(mockedResults);
      expect(requestUrl).toContain("/api/v1/transactions?limit=100&order=desc");
      expect(requestUrl).toContain("timestamp=gte");
      expect(requestUrl).toContain("timestamp=lte");
      expect(mockedNetwork).toHaveBeenCalledTimes(1);
    });

    it("should return null for non existing contract calls", async () => {
      const mockConsensusTimestamp1 = "1758733200.632122898";
      const mockConsensusTimestamp2 = "1758733300.632122898";
      const mockPayerAddress1 = "0.0.1234";
      const mockPayerAddress2 = "0.0.4321";
      mockedNetwork.mockResolvedValueOnce(
        getMockResponse({
          transactions: [
            {
              transfers: [],
              token_transfers: [],
              staking_reward_transfers: [],
              charged_tx_fee: 100,
              transaction_hash: "zzz",
              transaction_id: `${mockPayerAddress1}-${mockConsensusTimestamp1}`,
              consensus_timestamp: mockConsensusTimestamp1,
              result: "",
              entity_id: "0.0.1",
              name: "NOT_CONTRACTCALL",
            },
            {
              transfers: [],
              token_transfers: [],
              staking_reward_transfers: [],
              charged_tx_fee: 100,
              transaction_hash: "yyy",
              transaction_id: `${mockPayerAddress2}-${mockConsensusTimestamp2}`,
              consensus_timestamp: mockConsensusTimestamp2,
              result: "",
              entity_id: "0.0.2",
              name: "CONTRACTCALL",
            },
          ] satisfies Partial<HederaMirrorTransaction>[],
        }),
      );

      const result = await apiClient.findTransactionByContractCallV2({
        configOrCurrencyId: mockConfig,
        timestamp: mockConsensusTimestamp1,
        payerAddress: mockPayerAddress1,
      });

      expect(result).toEqual(null);
    });

    it("should call the correct endpoint and return null for empty transactions list", async () => {
      const mockConsensusTimestamp = "1758733200.632122898";
      const mockPayerAddress = "0.0.1234";

      mockedNetwork.mockResolvedValueOnce(
        getMockResponse({
          transactions: [],
        }),
      );

      const result = await apiClient.findTransactionByContractCallV2({
        configOrCurrencyId: mockConfig,
        timestamp: mockConsensusTimestamp,
        payerAddress: mockPayerAddress,
      });

      expect(result).toEqual(null);
    });
  });

  describe("estimateContractCallGas", () => {
    it("should call the correct endpoint and return estimated contract call gas", async () => {
      mockedNetwork.mockResolvedValueOnce(
        getMockResponse({
          result: "1000000000",
        }),
      );

      const result = await apiClient.estimateContractCallGas({
        configOrCurrencyId: mockConfig,
        senderEvmAddress: "0x0000000000000000000000000000000000000001",
        recipientEvmAddress: "0x0000000000000000000000000000000000000002",
        contractEvmAddress: "0x0000000000000000000000000000000000000002",
        amount: BigInt(1000),
      });
      const requestUrl = mockedNetwork.mock.calls[0][0].url;

      expect(result).toEqual(BigNumber("1000000000"));
      expect(requestUrl).toContain("/api/v1/contracts/call");
      expect(mockedNetwork).toHaveBeenCalledTimes(1);
    });
  });

  describe("getTransactionsByTimestampRange", () => {
    it("should include account.id query param if address is provided", async () => {
      mockedNetwork.mockResolvedValueOnce(
        getMockResponse({ transactions: [], links: { next: null } }),
      );

      await apiClient.getTransactionsByTimestampRange({
        configOrCurrencyId: mockConfig,
        address: "0.0.1234",
        startTimestamp: "gte:1000.000000000",
        endTimestamp: "lt:2000.000000000",
      });

      const requestUrl = mockedNetwork.mock.calls[0][0].url;
      expect(requestUrl).toContain("account.id=0.0.1234");
    });

    it("should include correct query params with timestamp range", async () => {
      mockedNetwork.mockResolvedValueOnce(
        getMockResponse({ transactions: [], links: { next: null } }),
      );

      await apiClient.getTransactionsByTimestampRange({
        configOrCurrencyId: mockConfig,
        startTimestamp: "gte:1000.000000000",
        endTimestamp: "lt:2000.000000000",
      });

      const requestUrl = mockedNetwork.mock.calls[0][0].url;
      expect(requestUrl).not.toContain("account.id=");
      expect(requestUrl).toContain("timestamp=gte%3A1000.000000000");
      expect(requestUrl).toContain("timestamp=lt%3A2000.000000000");
      expect(requestUrl).toContain("limit=100");
      expect(requestUrl).toContain("order=desc");
    });

    it("should return empty array when no transactions found", async () => {
      mockedNetwork.mockResolvedValueOnce(
        getMockResponse({ transactions: [], links: { next: null } }),
      );

      const result = await apiClient.getTransactionsByTimestampRange({
        configOrCurrencyId: mockConfig,
        startTimestamp: "gte:1000.000000000",
        endTimestamp: "lt:2000.000000000",
      });

      expect(result).toEqual([]);
      expect(mockedNetwork).toHaveBeenCalledTimes(1);
    });

    it("should return all transactions when only one page is needed", async () => {
      mockedNetwork.mockResolvedValueOnce(
        getMockResponse({
          transactions: [
            { consensus_timestamp: "1500.123456789" },
            { consensus_timestamp: "1750.987654321" },
          ],
          links: { next: null },
        }),
      );

      const result = await apiClient.getTransactionsByTimestampRange({
        configOrCurrencyId: mockConfig,
        startTimestamp: "gte:1000.000000000",
        endTimestamp: "lt:2000.000000000",
      });

      expect(result.map(tx => tx.consensus_timestamp)).toEqual([
        "1500.123456789",
        "1750.987654321",
      ]);
      expect(mockedNetwork).toHaveBeenCalledTimes(1);
    });

    it("should keep fetching all pages when links.next is present", async () => {
      mockedNetwork
        .mockResolvedValueOnce(
          getMockResponse({
            transactions: [{ consensus_timestamp: "1100.000000000" }],
            links: { next: "/next-1" },
          }),
        )
        .mockResolvedValueOnce(
          getMockResponse({
            transactions: [{ consensus_timestamp: "1200.000000000" }],
            links: { next: "/next-2" },
          }),
        )
        .mockResolvedValueOnce(
          getMockResponse({
            transactions: [{ consensus_timestamp: "1300.000000000" }],
            links: { next: null },
          }),
        );

      const result = await apiClient.getTransactionsByTimestampRange({
        configOrCurrencyId: mockConfig,
        startTimestamp: "gte:1000.000000000",
        endTimestamp: "lt:2000.000000000",
      });

      expect(result.map(tx => tx.consensus_timestamp)).toEqual([
        "1100.000000000",
        "1200.000000000",
        "1300.000000000",
      ]);
      expect(mockedNetwork).toHaveBeenCalledTimes(3);
    });

    it("should handle empty pages and continue fetching", async () => {
      mockedNetwork
        .mockResolvedValueOnce(
          getMockResponse({
            transactions: [{ consensus_timestamp: "1100.000000000" }],
            links: { next: "/next-1" },
          }),
        )
        .mockResolvedValueOnce(
          getMockResponse({
            transactions: [],
            links: { next: "/next-2" },
          }),
        )
        .mockResolvedValueOnce(
          getMockResponse({
            transactions: [{ consensus_timestamp: "1300.000000000" }],
            links: { next: null },
          }),
        );

      const result = await apiClient.getTransactionsByTimestampRange({
        configOrCurrencyId: mockConfig,
        startTimestamp: "gte:1000.000000000",
        endTimestamp: "lt:2000.000000000",
      });

      expect(result.map(tx => tx.consensus_timestamp)).toEqual([
        "1100.000000000",
        "1300.000000000",
      ]);
      expect(mockedNetwork).toHaveBeenCalledTimes(3);
    });
  });

  describe("getNodes", () => {
    it("should return all nodes if only one page is needed", async () => {
      mockedNetwork.mockResolvedValueOnce(
        getMockResponse({
          nodes: [
            { node_id: 0, node_account_id: "0.0.3" },
            { node_id: 1, node_account_id: "0.0.4" },
          ],
          links: { next: null },
        }),
      );

      const result = await apiClient.getNodes({
        configOrCurrencyId: mockConfig,
        fetchAllPages: true,
      });
      const requestUrl = mockedNetwork.mock.calls[0][0].url;

      expect(result.nodes.map(n => n.node_id)).toEqual([0, 1]);
      expect(requestUrl).toContain("/api/v1/network/nodes");
      expect(requestUrl).toContain("limit=100");
      expect(requestUrl).toContain("order=desc");
      expect(mockedNetwork).toHaveBeenCalledTimes(1);
    });

    it("should keep fetching if fetchAllPages and links.next is present", async () => {
      mockedNetwork
        .mockResolvedValueOnce(
          getMockResponse({
            nodes: [{ node_id: 0, node_account_id: "0.0.3" }],
            links: { next: "/next-1" },
          }),
        )
        .mockResolvedValueOnce(
          getMockResponse({
            nodes: [{ node_id: 1, node_account_id: "0.0.4" }],
            links: { next: "/next-2" },
          }),
        )
        .mockResolvedValueOnce(
          getMockResponse({
            nodes: [{ node_id: 2, node_account_id: "0.0.5" }],
            links: { next: null },
          }),
        );

      const result = await apiClient.getNodes({
        configOrCurrencyId: mockConfig,
        fetchAllPages: true,
      });

      expect(result.nodes.map(n => n.node_id)).toEqual([0, 1, 2]);
      expect(mockedNetwork).toHaveBeenCalledTimes(3);
    });

    it("should paginate if fetchAllPages is not set", async () => {
      mockedNetwork
        .mockResolvedValueOnce(
          getMockResponse({
            nodes: [
              { node_id: 0, node_account_id: "0.0.3" },
              { node_id: 1, node_account_id: "0.0.4" },
            ],
            links: { next: "/next-1" },
          }),
        )
        .mockResolvedValueOnce(
          getMockResponse({
            nodes: [{ node_id: 2, node_account_id: "0.0.5" }],
            links: { next: null },
          }),
        );

      const result = await apiClient.getNodes({
        configOrCurrencyId: mockConfig,
        limit: 2,
        fetchAllPages: false,
      });

      expect(result.nodes.map(tx => tx.node_id)).toEqual([0, 1]);
      expect(result.nextCursor).toBe("1");
      expect(mockedNetwork).toHaveBeenCalledTimes(1);
    });
  });
});
