import { getCryptoAssetsStore } from "@ledgerhq/cryptoassets/state";
import {
  decodeTokenAccountId,
  emptyHistoryCache,
  encodeTokenAccountId,
  findSubAccountById,
  isTokenAccount,
} from "@ledgerhq/ledger-wallet-framework/account";
import { mergeOps } from "@ledgerhq/ledger-wallet-framework/bridge/jsHelpers";
import { encodeOperationId } from "@ledgerhq/ledger-wallet-framework/operation";
import type { TokenCurrency } from "@ledgerhq/types-cryptoassets";
import type { Account, Operation, OperationType, TokenAccount } from "@ledgerhq/types-live";
import BigNumber from "bignumber.js";
import { HEDERA_OPERATION_TYPES } from "../constants";
import { estimateFees } from "../logic/estimateFees";
import {
  fromEVMAddress,
  toEVMAddress,
  getMemoFromBase64,
  isTokenAssociateTransaction,
  isValidExtra,
  base64ToUrlSafeBase64,
} from "../logic/utils";
import { getERC20Operations, parseThirdwebTransactionParams } from "../network/utils";
import type {
  HederaOperationExtra,
  Transaction,
  OperationERC20,
  HederaMirrorToken,
  HederaERC20TokenBalance,
  HederaThirdwebTransaction,
  ERC20OperationFields,
} from "../types";
import { estimateMaxSpendable } from "./estimateMaxSpendable";

interface CalculateAmountResult {
  amount: BigNumber;
  totalSpent: BigNumber;
}

const calculateCoinAmount = async ({
  account,
  transaction,
  operationType,
}: {
  account: Account;
  transaction: Transaction;
  operationType: Exclude<HEDERA_OPERATION_TYPES, HEDERA_OPERATION_TYPES.ContractCall>;
}): Promise<CalculateAmountResult> => {
  const estimatedFees = await estimateFees({ currency: account.currency, operationType });
  const amount = transaction.useAllAmount
    ? await estimateMaxSpendable({ account, transaction })
    : transaction.amount;

  return {
    amount,
    totalSpent: amount.plus(estimatedFees.tinybars),
  };
};

const calculateTokenAmount = async ({
  account,
  tokenAccount,
  transaction,
}: {
  account: Account;
  tokenAccount: TokenAccount;
  transaction: Transaction;
}): Promise<CalculateAmountResult> => {
  const amount = transaction.useAllAmount
    ? await estimateMaxSpendable({ account: tokenAccount, parentAccount: account, transaction })
    : transaction.amount;

  return {
    amount,
    totalSpent: amount,
  };
};

export const calculateAmount = ({
  account,
  transaction,
}: {
  account: Account;
  transaction: Transaction;
}): Promise<CalculateAmountResult> => {
  const subAccount = findSubAccountById(account, transaction?.subAccountId || "");
  const isTokenTransaction = isTokenAccount(subAccount);

  if (isTokenTransaction) {
    return calculateTokenAmount({ account, tokenAccount: subAccount, transaction });
  }

  const operationType: HEDERA_OPERATION_TYPES = isTokenAssociateTransaction(transaction)
    ? HEDERA_OPERATION_TYPES.TokenAssociate
    : HEDERA_OPERATION_TYPES.CryptoTransfer;

  return calculateCoinAmount({ account, transaction, operationType });
};

export const getSubAccounts = async ({
  ledgerAccountId,
  latestTokenOperations,
  mirrorTokens,
  erc20Tokens,
}: {
  ledgerAccountId: string;
  latestTokenOperations: Operation[];
  mirrorTokens: HederaMirrorToken[];
  erc20Tokens: HederaERC20TokenBalance[];
}): Promise<TokenAccount[]> => {
  // Creating a Map of Operations by TokenCurrencies in order to know which TokenAccounts should be synced as well
  const operationsByToken = new Map<TokenCurrency, Operation[]>();
  const subAccounts: TokenAccount[] = [];

  for (const tokenOperation of latestTokenOperations) {
    const { token } = await decodeTokenAccountId(tokenOperation.accountId);
    if (!token) continue;

    const isTokenListedInCAL = await getCryptoAssetsStore().findTokenByAddressInCurrency(
      token.contractAddress,
      token.parentCurrency.id,
    );
    if (!isTokenListedInCAL) continue;

    if (!operationsByToken.has(token)) {
      operationsByToken.set(token, []);
    }

    operationsByToken.get(token)?.push(tokenOperation);
  }

  // extract token accounts from existing operations
  for (const [token, tokenOperations] of operationsByToken.entries()) {
    const parentAccountId = ledgerAccountId;
    let balance: BigNumber | null = null;

    if (token.tokenType === "erc20") {
      const rawBalance = erc20Tokens.find(t => t.token.contractAddress === token.contractAddress);
      balance = rawBalance === undefined ? null : new BigNumber(rawBalance.balance);
    } else {
      const rawBalance = mirrorTokens.find(t => t.token_id === token.contractAddress)?.balance;
      balance = rawBalance === undefined ? null : new BigNumber(rawBalance);
    }

    if (!balance) {
      continue;
    }

    subAccounts.push({
      type: "TokenAccount",
      id: encodeTokenAccountId(parentAccountId, token),
      parentId: parentAccountId,
      token,
      balance,
      spendableBalance: balance,
      creationDate:
        tokenOperations.length > 0 ? tokenOperations[tokenOperations.length - 1].date : new Date(),
      operations: tokenOperations,
      operationsCount: tokenOperations.length,
      pendingOperations: [],
      balanceHistoryCache: emptyHistoryCache,
      swapHistory: [],
    });
  }

  // extract token accounts existing in the account's balance, but with no recorded operations yet
  // e.g. hts tokens added via association flow, without any subsequent activity
  // or erc20 tokens received from 3rd party
  for (const rawToken of [...mirrorTokens, ...erc20Tokens]) {
    const parentAccountId = ledgerAccountId;
    const rawBalance = rawToken.balance;
    const balance = new BigNumber(rawBalance);
    const isERC20 = "token" in rawToken;
    const tokenAddress = isERC20 ? rawToken.token.contractAddress : rawToken.token_id;
    const token = await getCryptoAssetsStore().findTokenByAddressInCurrency(tokenAddress, "hedera");

    if (!token) {
      continue;
    }

    const id = encodeTokenAccountId(parentAccountId, token);
    const operations = operationsByToken.get(token) ?? [];

    if (subAccounts.some(a => a.id === id)) {
      continue;
    }

    if (isERC20 && operations.length === 0 && balance.isZero()) {
      continue;
    }

    subAccounts.push({
      type: "TokenAccount",
      id,
      parentId: parentAccountId,
      token,
      balance,
      spendableBalance: balance,
      creationDate: isERC20
        ? new Date()
        : new Date(Number.parseFloat(rawToken.created_timestamp) * 1000),
      operations: [],
      operationsCount: 0,
      pendingOperations: [],
      balanceHistoryCache: emptyHistoryCache,
      swapHistory: [],
    });
  }

  return subAccounts;
};

type CoinOperationForOrphanChildOperation = Operation<HederaOperationExtra> &
  Required<Pick<Operation, "subOperations">>;

// create NONE coin operation that will be a parent of an orphan child operation
const makeCoinOperationForOrphanChildOperation = async (
  childOperation: Operation<HederaOperationExtra>,
): Promise<CoinOperationForOrphanChildOperation> => {
  const type = "NONE";
  const { accountId } = await decodeTokenAccountId(childOperation.accountId);
  const id = encodeOperationId(accountId, childOperation.hash, type);

  return {
    id,
    hash: childOperation.hash,
    type,
    value: new BigNumber(0),
    fee: new BigNumber(0),
    senders: [],
    recipients: [],
    blockHeight: childOperation.blockHeight,
    blockHash: childOperation.blockHash,
    transactionSequenceNumber: childOperation.transactionSequenceNumber,
    subOperations: [],
    nftOperations: [],
    internalOperations: [],
    accountId: "",
    date: childOperation.date,
    extra: {},
  };
};

// this util handles:
// - linking sub operations with coin operations, e.g. token transfer with fee payment
// - if possible, assigning `extra.associatedTokenId = mirrorToken.tokenId` based on operation's consensus timestamp
export const prepareOperations = async (
  coinOperations: Operation<HederaOperationExtra>[],
  tokenOperations: Operation<HederaOperationExtra>[],
): Promise<Operation<HederaOperationExtra>[]> => {
  const preparedCoinOperations = coinOperations.map(op => ({ ...op }));
  const preparedTokenOperations = tokenOperations.map(op => ({ ...op }));

  // loop through coin operations to prepare a map of hash => operations
  const coinOperationsByHash: Record<string, CoinOperationForOrphanChildOperation[]> = {};
  preparedCoinOperations.forEach(op => {
    if (!coinOperationsByHash[op.hash]) {
      coinOperationsByHash[op.hash] = [];
    }

    op.subOperations = [];
    coinOperationsByHash[op.hash].push(op as CoinOperationForOrphanChildOperation);
  });

  // loop through token operations to potentially copy them as a child operation of a coin operation
  for (const tokenOperation of preparedTokenOperations) {
    const { token } = await decodeTokenAccountId(tokenOperation.accountId);
    if (!token) continue;

    let mainOperations = coinOperationsByHash[tokenOperation.hash];

    if (!mainOperations?.length) {
      const noneOperation = await makeCoinOperationForOrphanChildOperation(tokenOperation);
      mainOperations = [noneOperation];
      preparedCoinOperations.push(noneOperation);
    }

    // ugly loop in loop but in theory, this can only be a 2 elements array maximum in the case of a self send
    for (const mainOperation of mainOperations) {
      mainOperation.subOperations.push(tokenOperation);
    }
  }

  return preparedCoinOperations;
};

/**
 * List of properties of a sub account that can be updated when 2 "identical" accounts are found
 */
const updatableSubAccountProperties = [
  { name: "balance", isOps: false },
  { name: "spendableBalance", isOps: false },
  { name: "balanceHistoryCache", isOps: false },
  { name: "operations", isOps: true },
  { name: "pendingOperations", isOps: true },
] as const satisfies { name: string; isOps: boolean }[];

/**
 * In charge of smartly merging sub accounts while maintaining references as much as possible
 */
export const mergeSubAccounts = (
  initialAccount: Account | undefined,
  newSubAccounts: TokenAccount[],
): Array<TokenAccount> => {
  const oldSubAccounts: Array<TokenAccount> | undefined = initialAccount?.subAccounts;

  if (!oldSubAccounts) {
    return newSubAccounts;
  }

  // map of already existing sub accounts by id
  const oldSubAccountsById: Record<string, TokenAccount> = {};
  for (const oldSubAccount of oldSubAccounts) {
    oldSubAccountsById[oldSubAccount.id] = oldSubAccount;
  }

  // looping through new sub accounts to compare them with already existing ones
  // already existing will be updated if necessary (see `updatableSubAccountProperties`)
  // new sub accounts will be added/pushed after already existing
  const newSubAccountsToAdd: TokenAccount[] = [];
  for (const newSubAccount of newSubAccounts) {
    const duplicatedAccount: TokenAccount | undefined = oldSubAccountsById[newSubAccount.id];

    if (!duplicatedAccount) {
      newSubAccountsToAdd.push(newSubAccount);
      continue;
    }

    const updates: Partial<TokenAccount> = {};
    for (const { name, isOps } of updatableSubAccountProperties) {
      if (!isOps) {
        if (newSubAccount[name] !== duplicatedAccount[name]) {
          // @ts-expect-error - TypeScript assumes all possible types could be assigned here
          updates[name] = newSubAccount[name];
        }
      } else {
        updates[name] = mergeOps(duplicatedAccount[name], newSubAccount[name]);
      }
    }

    // update the operationsCount in case the mergeOps changed it
    updates.operationsCount =
      updates.operations?.length || duplicatedAccount?.operations?.length || 0;

    // modify the map with the updated sub account with a new ref
    oldSubAccountsById[newSubAccount.id!] = {
      ...duplicatedAccount,
      ...updates,
    };
  }

  const updatedSubAccounts = Object.values(oldSubAccountsById);

  return [...updatedSubAccounts, ...newSubAccountsToAdd];
};

export const applyPendingExtras = (existing: Operation[], pending: Operation[]) => {
  const pendingOperationsByHash = new Map(pending.map(op => [op.hash, op]));

  return existing.map(op => {
    const pendingOp = pendingOperationsByHash.get(op.hash);
    if (!pendingOp) return op;
    if (!isValidExtra(op.extra)) return op;
    if (!isValidExtra(pendingOp.extra)) return op;

    return {
      ...op,
      extra: {
        ...pendingOp.extra,
        ...op.extra,
      },
    };
  });
};

export function patchOperationWithExtra(
  operation: Operation,
  extra: HederaOperationExtra,
): Operation {
  return {
    ...operation,
    extra,
    subOperations: (operation.subOperations ?? []).map(op => ({ ...op, extra })),
    nftOperations: (operation.nftOperations ?? []).map(op => ({ ...op, extra })),
  };
}

// TODO: remove once migration to new API is complete
// filter out CONTRACT_CALL operations based on pending and already existing ERC20 operations to avoid duplicates
export const removeDuplicatedContractCallOperations = (
  operations: Operation[],
  pendingOperationHashes: Set<string>,
  erc20OperationHashes: Set<string>,
): Operation[] => {
  return operations.filter(op => {
    if (op.type !== "CONTRACT_CALL") {
      return true;
    }

    const hashAlreadyExists =
      erc20OperationHashes.has(op.hash) || pendingOperationHashes.has(op.hash);

    return !hashAlreadyExists;
  });
};

// TODO: remove once migration to new API is complete
// loop over latestERC20Operations and prepare lists of transactions that should be patched and added
// - patching happens when we have a matching CONTRACT_CALL operation without blockHash set (mirror node transaction without ERC20 details)
// - adding happens when we have no matching operation
export const classifyERC20Operations = ({
  latestERC20Operations,
  operationsByHash,
  evmAccountAddress,
}: {
  latestERC20Operations: OperationERC20[];
  operationsByHash: Map<string, Operation>;
  evmAccountAddress: string | null;
}): {
  erc20OperationsToPatch: Map<string, OperationERC20>;
  erc20OperationsToAdd: Map<string, OperationERC20>;
} => {
  const erc20OperationsToPatch = new Map<string, OperationERC20>();
  const erc20OperationsToAdd = new Map<string, OperationERC20>();

  for (const erc20Operation of latestERC20Operations) {
    const hash = base64ToUrlSafeBase64(erc20Operation.mirrorTransaction.transaction_hash);
    const existingOp = operationsByHash.get(hash);
    const type =
      erc20Operation.thirdwebTransaction.decoded.params.from === evmAccountAddress ? "OUT" : "IN";

    if (!existingOp) {
      erc20OperationsToAdd.set(hash, erc20Operation);
      continue;
    }

    if (existingOp.type === "CONTRACT_CALL" && type === "OUT" && !existingOp.blockHash) {
      erc20OperationsToPatch.set(hash, erc20Operation);
      continue;
    }
  }

  return { erc20OperationsToPatch, erc20OperationsToAdd };
};

// TODO: remove once migration to new API is complete
// extracts common fields from an ERC20 operation
const buildERC20OperationFields = ({
  erc20Operation,
  relatedExistingOperation,
  variant,
  evmAddress,
}: {
  variant: "patch" | "add";
  evmAddress: string | null;
  erc20Operation: OperationERC20;
  relatedExistingOperation?: Operation;
}): ERC20OperationFields | null => {
  const decodedParams = parseThirdwebTransactionParams(erc20Operation.thirdwebTransaction);

  if (!decodedParams) {
    return null;
  }

  let type: OperationType = "OUT";
  const standard = "erc20";
  const blockHeight = 5;
  const blockHash = erc20Operation.thirdwebTransaction.blockHash;
  const consensusTimestamp = erc20Operation.mirrorTransaction.consensus_timestamp;
  const timestamp = new Date(Number.parseInt(consensusTimestamp.split(".")[0], 10) * 1000);
  const fee = BigNumber(erc20Operation.mirrorTransaction.charged_tx_fee);
  const value = BigNumber(decodedParams.value);
  const senderAddress = fromEVMAddress(decodedParams.from) ?? decodedParams.from;
  const recipientAddress = fromEVMAddress(decodedParams.to) ?? decodedParams.to;
  const memo = getMemoFromBase64(erc20Operation.mirrorTransaction.memo_base64);
  const extra: HederaOperationExtra = {
    ...(isValidExtra(relatedExistingOperation?.extra) && relatedExistingOperation.extra),
    ...(memo && { memo }),
    consensusTimestamp: erc20Operation.contractCallResult.timestamp,
    transactionId: erc20Operation.mirrorTransaction.transaction_id,
    gasConsumed: erc20Operation.contractCallResult.gas_consumed,
    gasLimit: erc20Operation.contractCallResult.gas_limit,
    gasUsed: erc20Operation.contractCallResult.gas_used,
  };

  if (variant === "add") {
    type = decodedParams.from === evmAddress ? "OUT" : "IN";
  }

  return {
    date: timestamp,
    type,
    fee,
    value,
    senders: [senderAddress],
    recipients: [recipientAddress],
    blockHeight,
    blockHash,
    extra,
    standard,
    contract: erc20Operation.token.contractAddress,
    hasFailed: false,
  };
};

// TODO: remove once migration to new API is complete
// patches an existing CONTRACT_CALL operation with ERC20 token operation details
export const patchContractCallOperation = ({
  relatedExistingOperation,
  ledgerAccountId,
  hash,
  erc20Fields,
  tokenOperation,
}: {
  relatedExistingOperation: Operation;
  ledgerAccountId: string;
  hash: string;
  erc20Fields: ERC20OperationFields;
  tokenOperation: Operation;
}): void => {
  Object.assign(relatedExistingOperation, {
    ...erc20Fields,
    id: encodeOperationId(ledgerAccountId, hash, "FEES"),
    type: "FEES",
    value: erc20Fields.fee,
    subOperations: [tokenOperation],
  });
};

// TODO: remove once migration to new API is complete
export const integrateERC20Operations = async ({
  ledgerAccountId,
  address,
  allOperations,
  latestERC20Transactions,
  pendingOperationHashes,
  erc20OperationHashes,
}: {
  ledgerAccountId: string;
  address: string;
  allOperations: Operation[];
  latestERC20Transactions: HederaThirdwebTransaction[];
  pendingOperationHashes: Set<string>;
  erc20OperationHashes: Set<string>;
}): Promise<{
  updatedOperations: Operation[];
  newERC20TokenOperations: Operation[];
}> => {
  const newERC20TokenOperations: Operation[] = [];
  const [latestERC20Operations, evmAddress] = await Promise.all([
    getERC20Operations(latestERC20Transactions),
    toEVMAddress(address),
  ]);

  // avoid duplicated CONTRACT_CALL operations if ERC20 operations are already present
  const uniqueOperations = removeDuplicatedContractCallOperations(
    allOperations,
    pendingOperationHashes,
    erc20OperationHashes,
  );

  // nothing to patch/add if no new ERC20 operations found
  if (latestERC20Operations.length === 0) {
    return {
      updatedOperations: uniqueOperations,
      newERC20TokenOperations,
    };
  }

  // create copy to avoid mutating original array and index by hash for easy lookup
  const updatedOperations = uniqueOperations.map(op => ({ ...op }));
  const operationsByHash = updatedOperations.reduce((acc, curr) => {
    acc.set(curr.hash, curr);
    return acc;
  }, new Map<string, Operation>());

  // split erc20 operations into patch and add lists
  const { erc20OperationsToPatch, erc20OperationsToAdd } = classifyERC20Operations({
    latestERC20Operations,
    operationsByHash,
    evmAccountAddress: evmAddress,
  });

  // patch existing operations with data from thirdweb
  for (const [hash, erc20Operation] of erc20OperationsToPatch.entries()) {
    const relatedExistingOperation = operationsByHash.get(hash);
    if (!relatedExistingOperation) continue;

    const erc20Fields = buildERC20OperationFields({
      variant: "patch",
      evmAddress,
      erc20Operation,
      relatedExistingOperation,
    });
    if (!erc20Fields) continue;

    const encodedTokenAccountId = encodeTokenAccountId(ledgerAccountId, erc20Operation.token);
    const encodedOperationId = encodeOperationId(encodedTokenAccountId, hash, erc20Fields.type);
    const tokenOperation: Operation<HederaOperationExtra> = {
      ...erc20Fields,
      id: encodedOperationId,
      accountId: encodedTokenAccountId,
      hash,
    };

    patchContractCallOperation({
      relatedExistingOperation,
      ledgerAccountId,
      hash,
      erc20Fields,
      tokenOperation,
    });

    newERC20TokenOperations.push(tokenOperation);
  }

  // create new operations for remaining ERC20 operations
  for (const [hash, erc20Operation] of erc20OperationsToAdd.entries()) {
    const erc20Fields = buildERC20OperationFields({
      variant: "add",
      evmAddress,
      erc20Operation,
    });
    if (!erc20Fields) continue;

    const encodedTokenAccountId = encodeTokenAccountId(ledgerAccountId, erc20Operation.token);
    const encodedOperationId = encodeOperationId(encodedTokenAccountId, hash, erc20Fields.type);
    const tokenOperation: Operation<HederaOperationExtra> = {
      ...erc20Fields,
      id: encodedOperationId,
      accountId: encodedTokenAccountId,
      hash,
    };

    const coinOperation: Operation<HederaOperationExtra> =
      erc20Fields.type === "OUT"
        ? {
            ...erc20Fields,
            id: encodeOperationId(ledgerAccountId, hash, "FEES"),
            accountId: ledgerAccountId,
            type: "FEES",
            value: erc20Fields.fee,
            hash,
          }
        : await makeCoinOperationForOrphanChildOperation(tokenOperation);

    coinOperation.subOperations = [tokenOperation];
    updatedOperations.push(coinOperation);
    newERC20TokenOperations.push(tokenOperation);
  }

  // ensure operations lists are sorted correctly
  updatedOperations.sort((a, b) => b.date.getTime() - a.date.getTime());
  newERC20TokenOperations.sort((a, b) => b.date.getTime() - a.date.getTime());

  return {
    updatedOperations,
    newERC20TokenOperations,
  };
};
