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

jest.mock("@ledgerhq/live-network");
const mockedNetwork = jest.mocked(network);

describe("getAccountTransactions", () => {
  beforeEach(() => {
    jest.resetAllMocks();
  });

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

    await apiClient.getAccountTransactions({
      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({
      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({
      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", () => {
  const mockAddress = "0.0.1234";

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

  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(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(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", () => {
  beforeEach(() => {
    jest.resetAllMocks();
  });

  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("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("0.0.1234");

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

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

  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();
    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();

    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()).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(
      "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("findTransactionByContractCall", () => {
  beforeEach(() => {
    jest.clearAllMocks();
  });

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

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

    const result = await apiClient.findTransactionByContractCall(
      "xxxxxxxxxxxxxxxxxxxx",
      "0.0.1234",
    );
    const requestUrl = mockedNetwork.mock.calls[0][0].url;

    expect(result).toEqual(mockedResults);
    expect(requestUrl).toContain("/api/v1/transactions?timestamp=");
    expect(mockedNetwork).toHaveBeenCalledTimes(1);
  });

  it("should call the correct endpoint and return null for non existing contract calls", async () => {
    mockedNetwork.mockResolvedValueOnce(
      getMockResponse({
        transactions: [
          {
            transfers: [],
            token_transfers: [],
            staking_reward_transfers: [],
            charged_tx_fee: 100,
            transaction_hash: "xxxxxxxxxxxxx",
            consensus_timestamp: "xxxxxxxxxxxxx",
            result: "xxxxxxxxxxxxx",
            entity_id: "0.0.1234",
            name: "NOT_CONTRACTCALL",
          },
          {
            transfers: [],
            token_transfers: [],
            staking_reward_transfers: [],
            charged_tx_fee: 100,
            transaction_hash: "xxxxxxxxxxxxx",
            consensus_timestamp: "xxxxxxxxxxxxx",
            result: "xxxxxxxxxxxxx",
            entity_id: "0.0.1111",
            name: "CONTRACTCALL",
          },
        ] satisfies Partial<HederaMirrorTransaction>[],
      }),
    );

    const result = await apiClient.findTransactionByContractCall(
      "xxxxxxxxxxxxxxxxxxxx",
      "0.0.1234",
    );
    const requestUrl = mockedNetwork.mock.calls[0][0].url;

    expect(result).toEqual(null);
    expect(requestUrl).toContain("/api/v1/transactions?timestamp=");
    expect(mockedNetwork).toHaveBeenCalledTimes(1);
  });

  it("should call the correct endpoint and return null for empty transactions list", async () => {
    mockedNetwork.mockResolvedValueOnce(
      getMockResponse({
        transactions: [],
      }),
    );

    const result = await apiClient.findTransactionByContractCall(
      "xxxxxxxxxxxxxxxxxxxx",
      "0.0.1234",
    );
    const requestUrl = mockedNetwork.mock.calls[0][0].url;

    expect(result).toEqual(null);
    expect(requestUrl).toContain("/api/v1/transactions?timestamp=");
    expect(mockedNetwork).toHaveBeenCalledTimes(1);
  });
});

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

  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({
      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({
      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({
      timestamp: mockConsensusTimestamp,
      payerAddress: mockPayerAddress,
    });

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

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

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

    const result = await apiClient.getERC20Balance(
      "0x0000000000000000000000000000000000000001",
      "0x0000000000000000000000000000000000000002",
    );
    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("estimateContractCallGas", () => {
  beforeEach(() => {
    jest.clearAllMocks();
  });

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

    const result = await apiClient.estimateContractCallGas(
      "0x0000000000000000000000000000000000000001",
      "0x0000000000000000000000000000000000000002",
      "0xa9059cbb000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000186a0",
      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", () => {
  beforeEach(() => {
    jest.resetAllMocks();
  });

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

    await apiClient.getTransactionsByTimestampRange({
      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({
      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({
      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({
      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({
      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({
      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", () => {
  beforeEach(() => {
    jest.resetAllMocks();
  });

  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({ 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({ 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({
      limit: 2,
      fetchAllPages: false,
    });

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