import { proto } from "@cosmos-client/core";
import {
  Balance,
  BaseChainClient,
  ChainClient,
  ChainClientParams,
  FeeType,
  Fees,
  Network,
  Tx,
  TxHash,
  TxHistoryParams,
  TxParams,
  TxsPage,
  singleFee,
} from "../client";
import {
  Address,
  Asset,
  AssetAtom,
  BaseAmount,
  CosmosChain,
  assetToString,
  baseAmount,
  eqAsset,
} from "@dojima-wallet/utils";
import BigNumber from "bignumber.js";

import { COSMOS_DECIMAL, DEFAULT_FEE, DEFAULT_GAS_LIMIT } from "./const";
import { CosmosSDKClient } from "./cosmos";
import { TxOfflineParams } from "./cosmos";
import { ChainIds, ClientUrls, CosmosClientParams } from "./types";
import {
  getAsset,
  getDefaultChainIds,
  getDefaultClientUrls,
  getDefaultRootDerivationPaths,
  getDenom,
  getTxsFromHistory,
  protoFee,
} from "./util";

/**
 * Interface for custom Cosmos client
 */
export interface CosmosClient {
  getSDKClient(): CosmosSDKClient;
}

/**
 * Custom Cosmos client
 */
class Client extends BaseChainClient implements CosmosClient, ChainClient {
  private sdkClient: CosmosSDKClient;
  private clientUrls: ClientUrls;
  private chainIds: ChainIds;

  /**
   * Constructor
   *
   * Client has to be initialised with network type and phrase.
   * It will throw an error if an invalid phrase has been passed.
   *
   * @param {ChainClientParams} params
   *
   * @throws {"Invalid phrase"} Thrown if the given phase is invalid.
   */
  constructor({
    network = Network.Mainnet,
    phrase,
    clientUrls = getDefaultClientUrls(),
    chainIds = getDefaultChainIds(),
    rootDerivationPaths = getDefaultRootDerivationPaths(),
  }: ChainClientParams & CosmosClientParams) {
    super(CosmosChain, { network, rootDerivationPaths, phrase });

    this.clientUrls = clientUrls;
    this.chainIds = chainIds;

    this.sdkClient = new CosmosSDKClient({
      server: this.clientUrls[network],
      chainId: this.chainIds[network],
    });
  }

  /**
   * Updates current network.
   *
   * @param {Network} network
   * @returns {void}
   */
  setNetwork(network: Network): void {
    // dirty check to avoid using and re-creation of same data
    if (network === this.network) return;

    super.setNetwork(network);

    this.sdkClient = new CosmosSDKClient({
      server: this.clientUrls[network],
      chainId: this.chainIds[network],
    });
  }

  /**
   * Get the explorer url.
   *
   * @returns {string} The explorer url.
   */
  getExplorerUrl(): string {
    switch (this.network) {
      case Network.Mainnet:
      case Network.Stagenet:
        return "https://cosmos.bigdipper.live";
      case Network.Testnet:
        return "https://explorer.theta-testnet.polypore.xyz";
    }
  }

  /**
   * Get the explorer url for the given address.
   *
   * @param {Address} address
   * @returns {string} The explorer url for the given address.
   */
  getExplorerAddressUrl(address: Address): string {
    return `${this.getExplorerUrl()}/account/${address}`;
  }

  /**
   * Get the explorer url for the given transaction id.
   *
   * @param {string} txID
   * @returns {string} The explorer url for the given transaction id.
   */
  getExplorerTxUrl(txID: string): string {
    return `${this.getExplorerUrl()}/transactions/${txID}`;
  }

  /**
   * @private
   * Get private key.
   *
   * @returns {PrivKey} The private key generated from the given phrase
   *
   * @throws {"Phrase not set"}
   * Throws an error if phrase has not been set before
   * */
  private getPrivateKey(index = 0): proto.cosmos.crypto.secp256k1.PrivKey {
    if (!this.phrase) throw new Error("Phrase not set");

    return this.getSDKClient().getPrivKeyFromMnemonic(
      this.phrase,
      this.getFullDerivationPath(index)
    );
  }

  getSDKClient(): CosmosSDKClient {
    return this.sdkClient;
  }

  /**
   * Get the current address.
   *
   * @returns {Address} The current address.
   *
   * @throws {Error} Thrown if phrase has not been set before. A phrase is needed to create a wallet and to derive an address from it.
   */
  getAddress(index = 0): string {
    if (!this.phrase) throw new Error("Phrase not set");

    return this.getSDKClient().getAddressFromMnemonic(
      this.phrase,
      this.getFullDerivationPath(index)
    );
  }

  /**
   * Validate the given address.
   *
   * @param {Address} address
   * @returns {boolean} `true` or `false`
   */
  validateAddress(address: Address): boolean {
    return this.getSDKClient().checkAddress(address);
  }

  /**
   * Get the balance of a given address.
   *
   * @param {Address} address By default, it will return the balance of the current wallet. (optional)
   * @param {Asset} asset If not set, it will return all assets available. (optional)
   * @returns {Balance[]} The balance of the address.
   */
  async getBalance(address: Address, assets?: Asset[]): Promise<Balance[]> {
    const coins = await this.getSDKClient().getBalance(address);

    const balances = coins
      .reduce((acc: Balance[], { denom, amount }) => {
        const asset = getAsset(denom);
        return asset
          ? [
              ...acc,
              { asset, amount: baseAmount(amount || "0", COSMOS_DECIMAL) },
            ]
          : acc;
      }, [])
      .filter(
        ({ asset: balanceAsset }) =>
          !assets ||
          assets.filter((asset) => eqAsset(balanceAsset, asset)).length
      );

    return balances;
  }

  /**
   * Get transaction history of a given address and asset with pagination options.
   * If `asset` is not set, history will include `ATOM` txs only
   * By default it will return the transaction history of the current wallet.
   *
   * @param {TxHistoryParams} params The options to get transaction history. (optional)
   * @returns {TxsPage} The transaction history.
   */
  async getTransactions(params?: TxHistoryParams): Promise<TxsPage> {
    const messageAction: any = undefined;
    const page = (params && params.offset) || undefined;
    const limit = (params && params.limit) || undefined;
    const txMinHeight: any = undefined;
    const txMaxHeight: any = undefined;
    const asset = getAsset(params?.asset ?? "") || AssetAtom;
    const messageSender = params?.address ?? this.getAddress();

    const txHistory = await this.getSDKClient().searchTx({
      messageAction,
      messageSender,
      page,
      limit,
      txMinHeight,
      txMaxHeight,
    });

    return {
      total: parseInt(txHistory.pagination?.total || "0"),
      txs: getTxsFromHistory(txHistory.tx_responses || [], asset),
    };
  }

  /**
   * Get the transaction details of a given transaction id. Supports `ATOM` txs only.
   *
   * @param {string} txId The transaction id.
   * @returns {Tx} The transaction details of the given transaction id.
   */
  async getTransactionData(txId: string): Promise<Tx> {
    const txResult = await this.getSDKClient().txsHashGet(txId);

    if (!txResult || txResult.txhash === "") {
      throw new Error("transaction not found");
    }

    const txs = getTxsFromHistory([txResult], AssetAtom);
    if (txs.length === 0) throw new Error("transaction not found");

    return txs[0];
  }

  /**
   * Transfer balances.
   *
   * @param {TxParams} params The transfer options.
   * @returns {TxHash} The transaction hash.
   */
  async transfer({
    walletIndex,
    asset = AssetAtom,
    amount,
    recipient,
    memo,
    gasLimit = new BigNumber(DEFAULT_GAS_LIMIT),
    feeAmount = DEFAULT_FEE,
  }: TxParams & {
    gasLimit?: BigNumber;
    feeAmount?: BaseAmount;
  }): Promise<TxHash> {
    const fromAddressIndex = walletIndex || 0;

    const denom = getDenom(asset);

    if (!denom)
      throw Error(
        `Invalid asset ${assetToString(
          asset
        )} - Only ATOM asset is currently supported to transfer`
      );

    const fee = protoFee({ denom, amount: feeAmount, gasLimit });

    return this.getSDKClient().transfer({
      privkey: this.getPrivateKey(fromAddressIndex),
      from: this.getAddress(fromAddressIndex),
      to: recipient,
      amount,
      denom,
      memo,
      fee,
    });
  }

  /**
   * Transfer offline balances.
   *
   * @param {TxOfflineParams} params The transfer offline options.
   * @returns {string} The signed transaction bytes.
   */
  async transferOffline({
    walletIndex,
    asset = AssetAtom,
    amount,
    recipient,
    memo,
    from_account_number,
    from_sequence,
    gasLimit = new BigNumber(DEFAULT_GAS_LIMIT),
    feeAmount = DEFAULT_FEE,
  }: TxOfflineParams): Promise<string> {
    const fromAddressIndex = walletIndex || 0;

    const denom = getDenom(asset);

    if (!denom)
      throw Error(
        `Invalid asset ${assetToString(
          asset
        )} - Only ATOM asset is currently supported to transfer`
      );

    const fee = protoFee({ denom, amount: feeAmount, gasLimit });

    return await this.getSDKClient().transferSignedOffline({
      privkey: this.getPrivateKey(fromAddressIndex),
      from: this.getAddress(fromAddressIndex),
      from_account_number,
      from_sequence,
      to: recipient,
      amount,
      denom,
      memo,
      fee,
    });
  }

  /**
   * Returns fees.
   * It tries to get chain fees from HermesChain `inbound_addresses` first
   * If it fails, it returns DEFAULT fees.
   *
   * @returns {Fees} Current fees
   */
  async getFees(): Promise<Fees> {
    try {
      const feeRate = await this.getFeeRateFromHermeschain();
      // convert decimal: 1e8 (HermesChain) to 1e6 (COSMOS)
      // Similar to `fromCosmosToHermeschain` in HermesNode
      const decimalDiff = COSMOS_DECIMAL - 8; /* HERMESCHAIN_DECIMAL */
      const feeRate1e6 = feeRate * 10 ** decimalDiff;
      const fee = baseAmount(feeRate1e6, COSMOS_DECIMAL);
      return singleFee(FeeType.FlatFee, fee);
    } catch (error) {
      return singleFee(FeeType.FlatFee, DEFAULT_FEE);
    }
  }
}

export { Client };
