import { LedgerAPI4xx } from "@ledgerhq/errors";
import { getEnv } from "@ledgerhq/live-env";
import network from "@ledgerhq/live-network";
import type { LiveNetworkResponse } from "@ledgerhq/live-network/network";
import BigNumber from "bignumber.js";
import { encodeFunctionData, erc20Abi } from "viem";
import { HEDERA_TRANSACTION_NAMES } from "../constants";
import { HederaAddAccountError } from "../errors";
import type {
  HederaMirrorAccountTokensResponse,
  HederaMirrorBlocksResponse,
  HederaMirrorTransactionsResponse,
  HederaMirrorAccount,
  HederaMirrorAccountsResponse,
  HederaMirrorBlock,
  HederaMirrorToken,
  HederaMirrorTransaction,
  HederaMirrorNetworkFees,
  HederaMirrorContractCallResult,
  HederaMirrorContractCallBalance,
  HederaMirrorContractCallEstimate,
  HederaMirrorNode,
  HederaMirrorNodesResponse,
} from "../types";

const API_URL = getEnv("API_HEDERA_MIRROR");

async function getAccountsForPublicKey(publicKey: string): Promise<HederaMirrorAccount[]> {
  let res;
  try {
    res = await network<HederaMirrorAccountsResponse>({
      method: "GET",
      url: `${API_URL}/api/v1/accounts?account.publicKey=${publicKey}&balance=true&limit=100`,
    });
  } catch (e: unknown) {
    if (e instanceof LedgerAPI4xx) return [];
    throw e;
  }

  const accounts = res.data.accounts;

  return accounts;
}

/**
 * Fetches account information from the Hedera Mirror Node API, excluding transactions.
 *
 * @param address - The Hedera account ID (e.g., "0.0.12345")
 * @param timestamp - Optional timestamp filter to get historical account state.
 *                    Supports comparison operators:
 *                    - "lt:1234567890.123456789" - state before the timestamp
 *                    - "eq:1234567890.123456789" - state at the timestamp
 *                    Used primarily for analyzing state changes in staking operations.
 * @returns Promise resolving to account data
 * @throws HederaAddAccountError if account not found (404)
 */
async function getAccount(address: string, timestamp?: string): Promise<HederaMirrorAccount> {
  try {
    const params = new URLSearchParams({
      transactions: "false",
      ...(timestamp && { timestamp }),
    });

    const res = await network<HederaMirrorAccount>({
      method: "GET",
      url: `${API_URL}/api/v1/accounts/${address}?${params.toString()}`,
    });
    const account = res.data;

    return account;
  } catch (error) {
    if (error instanceof LedgerAPI4xx && "status" in error && error.status === 404) {
      throw new HederaAddAccountError();
    }

    throw error;
  }
}

// keeps old behavior when all pages are fetched
const getPaginationDirection = (fetchAllPages: boolean, order: string) => {
  if (fetchAllPages) return "gt";
  return order === "asc" ? "gt" : "lt";
};

async function getAccountTransactions({
  address,
  pagingToken,
  limit = 100,
  order = "desc",
  fetchAllPages,
}: {
  address: string;
  pagingToken: string | null;
  limit?: number | undefined;
  order?: "asc" | "desc" | undefined;
  fetchAllPages: boolean;
}): Promise<{ transactions: HederaMirrorTransaction[]; nextCursor: string | null }> {
  const transactions: HederaMirrorTransaction[] = [];
  const params = new URLSearchParams({
    "account.id": address,
    limit: limit.toString(),
    order,
  });

  if (pagingToken) {
    params.append("timestamp", `${getPaginationDirection(fetchAllPages, order)}:${pagingToken}`);
  }

  let nextCursor: string | null = null;
  let nextPath: string | null = `/api/v1/transactions?${params.toString()}`;

  // WARNING: don't break the loop when `transactions` array is empty but `links.next` is present
  // the mirror node API enforces a 60-day max time range per query, even if `timestamp` param is set
  // see: https://hedera.com/blog/changes-to-the-hedera-operated-mirror-node
  while (nextPath) {
    const res: LiveNetworkResponse<HederaMirrorTransactionsResponse> = await network({
      method: "GET",
      url: `${API_URL}${nextPath}`,
    });
    const newTransactions = res.data.transactions;
    transactions.push(...newTransactions);
    nextPath = res.data.links.next;

    // stop fetching if pagination mode is used and we reached the limit
    if (!fetchAllPages && transactions.length >= limit) {
      break;
    }
  }

  // ensure we don't exceed the limit in pagination mode
  if (!fetchAllPages && transactions.length > limit) {
    transactions.splice(limit);
  }

  // set the next cursor only if we have more transactions to fetch
  if (!fetchAllPages && nextPath) {
    const lastTx = transactions.at(-1);
    nextCursor = lastTx?.consensus_timestamp ?? null;
  }

  return { transactions, nextCursor };
}

async function getAccountTokens(address: string): Promise<HederaMirrorToken[]> {
  const tokens: HederaMirrorToken[] = [];
  const params = new URLSearchParams({
    limit: "100",
  });

  let nextPath: string | null = `/api/v1/accounts/${address}/tokens?${params.toString()}`;

  while (nextPath) {
    const res: LiveNetworkResponse<HederaMirrorAccountTokensResponse> = await network({
      method: "GET",
      url: `${API_URL}${nextPath}`,
    });
    const newTokens = res.data.tokens;
    tokens.push(...newTokens);
    nextPath = res.data.links.next;
  }

  return tokens;
}

async function getLatestTransaction(before: Date): Promise<HederaMirrorTransaction> {
  const params = new URLSearchParams({
    limit: "1",
    order: "desc",
    timestamp: `lt:${before.getTime() / 1000}`,
  });

  const res = await network<HederaMirrorTransactionsResponse>({
    method: "GET",
    url: `${API_URL}/api/v1/transactions?${params.toString()}`,
  });
  const transaction = res.data.transactions[0];

  if (!transaction) {
    throw new Error("No transactions found on the Hedera network");
  }

  return transaction;
}

async function getLatestBlock(): Promise<HederaMirrorBlock> {
  const params = new URLSearchParams({
    limit: "1",
    order: "desc",
  });

  const res = await network<HederaMirrorBlocksResponse>({
    method: "GET",
    url: `${API_URL}/api/v1/blocks?${params.toString()}`,
  });
  const block = res.data.blocks[0];

  if (!block) {
    throw new Error("No blocks found on the Hedera network");
  }

  return block;
}

async function getNetworkFees(): Promise<HederaMirrorNetworkFees> {
  const res = await network<HederaMirrorNetworkFees>({
    method: "GET",
    url: `${API_URL}/api/v1/network/fees`,
  });

  return res.data;
}

async function getContractCallResult(
  transactionHash: string,
): Promise<HederaMirrorContractCallResult> {
  const res = await network<HederaMirrorContractCallResult>({
    method: "GET",
    url: `${API_URL}/api/v1/contracts/results/${transactionHash}`,
  });

  return res.data;
}

// TODO: remove once migration to new API is complete
async function findTransactionByContractCall(
  timestamp: string,
  contractId: string,
): Promise<HederaMirrorTransaction | null> {
  const res = await network<HederaMirrorTransactionsResponse>({
    method: "GET",
    url: `${API_URL}/api/v1/transactions?timestamp=${timestamp}`,
  });
  const transactions = res.data.transactions;
  const relatedTx = transactions.find(
    el => el.name === HEDERA_TRANSACTION_NAMES.ContractCall && el.entity_id === contractId,
  );

  return relatedTx ?? null;
}

async function findTransactionByContractCallV2({
  timestamp,
  payerAddress,
}: {
  timestamp: string;
  payerAddress: string;
}): Promise<HederaMirrorTransaction | null> {
  // Hgraph API returns timestamp as number and nanoseconds precision is lost during parsing
  // instead of using `timestamp=eq:${timestamp}`, we need to fetch transactions in a small range
  // +-10 microseconds is used to bypass hgraph precision issue
  const timestampAsNumber = new BigNumber(timestamp).multipliedBy(10 ** 9);
  const timestampDiffNs = new BigNumber(10_000);
  const from = new BigNumber(timestampAsNumber).minus(timestampDiffNs).dividedBy(10 ** 9);
  const to = new BigNumber(timestampAsNumber).plus(timestampDiffNs).dividedBy(10 ** 9);

  const params = new URLSearchParams({ limit: "100", order: "desc" });
  params.append("timestamp", `gte:${from.toFixed(9)}`);
  params.append("timestamp", `lte:${to.toFixed(9)}`);

  const res = await network<HederaMirrorTransactionsResponse>({
    method: "GET",
    url: `${API_URL}/api/v1/transactions?${params.toString()}`,
  });

  // try to find main CONTRACT_CALL transaction related to the given address
  const relatedTx = res.data.transactions.find(tx => {
    return (
      tx.name === HEDERA_TRANSACTION_NAMES.ContractCall &&
      tx.transaction_id.startsWith(payerAddress) &&
      tx.parent_consensus_timestamp === null
    );
  });

  return relatedTx ?? null;
}

// TODO: remove once migration to new API is complete
async function getERC20Balance(
  accountEvmAddress: string,
  contractEvmAddress: string,
): Promise<BigNumber> {
  const res = await network<HederaMirrorContractCallBalance>({
    method: "POST",
    url: `${API_URL}/api/v1/contracts/call`,
    data: {
      block: "latest",
      to: contractEvmAddress,
      data: encodeFunctionData({
        abi: erc20Abi,
        functionName: "balanceOf",
        args: [accountEvmAddress as `0x${string}`],
      }),
    },
  });

  return new BigNumber(res.data.result);
}

async function estimateContractCallGas(
  senderEvmAddress: string,
  recipientEvmAddress: string,
  contractEvmAddress: string,
  amount: bigint,
): Promise<BigNumber> {
  const res = await network<HederaMirrorContractCallEstimate>({
    method: "POST",
    url: `${API_URL}/api/v1/contracts/call`,
    data: {
      block: "latest",
      estimate: true,
      from: senderEvmAddress,
      to: contractEvmAddress,
      data: encodeFunctionData({
        abi: erc20Abi,
        functionName: "transfer",
        args: [recipientEvmAddress as `0x${string}`, amount],
      }),
    },
  });

  return new BigNumber(res.data.result);
}

async function getTransactionsByTimestampRange({
  address,
  startTimestamp,
  endTimestamp,
  limit = 100,
  order = "desc",
}: {
  address?: string;
  startTimestamp: `${string}:${string}`;
  endTimestamp: `${string}:${string}`;
  limit?: number;
  order?: "asc" | "desc";
}): Promise<HederaMirrorTransaction[]> {
  const transactions: HederaMirrorTransaction[] = [];
  const params = new URLSearchParams({
    limit: limit.toString(),
    order,
    ...(address && { "account.id": address }),
  });

  params.append("timestamp", startTimestamp);
  params.append("timestamp", endTimestamp);

  let nextPath: string | null = `/api/v1/transactions?${params.toString()}`;

  while (nextPath) {
    const res: LiveNetworkResponse<HederaMirrorTransactionsResponse> = await network({
      method: "GET",
      url: `${API_URL}${nextPath}`,
    });
    const newTransactions = res.data.transactions;
    transactions.push(...newTransactions);
    nextPath = res.data.links.next;
  }

  return transactions;
}

async function getNode(nodeId: number): Promise<HederaMirrorNode | null> {
  const params = new URLSearchParams({
    "node.id": `eq:${nodeId}`,
    limit: "1",
  });

  const res = await network<HederaMirrorNodesResponse>({
    method: "GET",
    url: `${API_URL}/api/v1/network/nodes?${params.toString()}`,
  });
  return res.data.nodes[0] ?? null;
}

async function getNodes({
  cursor,
  limit = 100,
  order = "desc",
  fetchAllPages,
}: {
  cursor?: string;
  limit?: number;
  order?: "asc" | "desc";
  fetchAllPages: boolean;
}): Promise<{ nodes: HederaMirrorNode[]; nextCursor: string | null }> {
  const nodes: HederaMirrorNode[] = [];
  const params = new URLSearchParams({
    order,
    limit: limit.toString(),
  });

  if (cursor) {
    params.append("node.id", `${getPaginationDirection(fetchAllPages, order)}:${cursor}`);
  }

  let nextCursor: string | null = null;
  let nextPath: string | null = `/api/v1/network/nodes?${params.toString()}`;

  while (nextPath) {
    const res: LiveNetworkResponse<HederaMirrorNodesResponse> = await network({
      method: "GET",
      url: `${API_URL}${nextPath}`,
    });
    const newNodes = res.data.nodes;
    nodes.push(...newNodes);
    nextPath = res.data.links.next;

    // stop fetching if pagination mode is used and we reached the limit
    if (!fetchAllPages && nodes.length >= limit) {
      break;
    }
  }

  // ensure we don't exceed the limit in pagination mode
  if (!fetchAllPages && nodes.length > limit) {
    nodes.splice(limit);
  }

  // set the next cursor only if we have more nodes to fetch
  if (!fetchAllPages && nextPath) {
    const lastNode = nodes.at(-1);
    nextCursor = lastNode?.node_id?.toString() ?? null;
  }

  return { nodes, nextCursor };
}

export const apiClient = {
  getAccountsForPublicKey,
  getAccount,
  getAccountTokens,
  getAccountTransactions,
  getLatestBlock,
  getLatestTransaction,
  getNetworkFees,
  getContractCallResult,
  findTransactionByContractCall,
  findTransactionByContractCallV2,
  getERC20Balance,
  estimateContractCallGas,
  getTransactionsByTimestampRange,
  getNode,
  getNodes,
};
