import TronWeb, { Transaction as TronTransaction } from 'tronweb'
import { pick, get, cloneDeep } from 'lodash'
import {
  BalanceResult,
  BasePayments,
  TransactionStatus,
  FeeLevel,
  FeeOption,
  FeeRateType,
  FeeOptionCustom,
  ResolvedFeeOption,
  Payport,
  FromTo,
  ResolveablePayport,
} from '@faast/payments-common'
import { isType, DelegateLogger, Logger } from '@faast/ts-common'

import {
  TronTransactionInfo,
  TronUnsignedTransaction,
  TronSignedTransaction,
  TronBroadcastResult,
  CreateTransactionOptions,
  GetPayportOptions,
  BaseTronPaymentsConfig,
  TronWebTransaction,
} from './types'
import { toBaseDenominationNumber, isValidAddress, isValidPayport } from './helpers'
import { toError } from './utils'
import {
  DEFAULT_FULL_NODE,
  DEFAULT_EVENT_SERVER,
  DEFAULT_SOLIDITY_NODE,
  MIN_BALANCE_SUN,
  MIN_BALANCE_TRX,
  PACKAGE_NAME,
  DEFAULT_FEE_LEVEL,
} from './constants'
import { TronPaymentsUtils } from './TronPaymentsUtils'

export abstract class BaseTronPayments<Config extends BaseTronPaymentsConfig> extends TronPaymentsUtils
  implements
    BasePayments<Config, TronUnsignedTransaction, TronSignedTransaction, TronBroadcastResult, TronTransactionInfo> {
  // You may notice that many function blocks are enclosed in a try/catch.
  // I had to do this because tronweb thinks it's a good idea to throw
  // strings instead of Errors and now we need to convert them all ourselves
  // to be consistent.

  fullNode: string
  solidityNode: string
  eventServer: string
  tronweb: TronWeb

  constructor(config: Config) {
    super(config)
    this.fullNode = config.fullNode || DEFAULT_FULL_NODE
    this.solidityNode = config.solidityNode || DEFAULT_SOLIDITY_NODE
    this.eventServer = config.eventServer || DEFAULT_EVENT_SERVER
    this.logger = new DelegateLogger(config.logger, PACKAGE_NAME)
    this.tronweb = new TronWeb(this.fullNode, this.solidityNode, this.eventServer)
  }

  abstract getFullConfig(): Config
  abstract getPublicConfig(): Config
  abstract getAccountId(index: number): string
  abstract getAccountIds(): string[]
  abstract async getPayport(index: number, options?: GetPayportOptions): Promise<Payport>
  abstract async getPrivateKey(index: number): Promise<string>

  requiresBalanceMonitor() {
    return false
  }

  async getBalance(resolveablePayport: ResolveablePayport): Promise<BalanceResult> {
    try {
      const payport = await this.resolvePayport(resolveablePayport)
      const balanceSun = await this.tronweb.trx.getBalance(payport.address)
      this.logger.debug(`trx.getBalance(${payport.address}) -> ${balanceSun}`)
      const sweepable = this.canSweepBalance(balanceSun)
      return {
        confirmedBalance: this.toMainDenomination(balanceSun).toString(),
        unconfirmedBalance: '0',
        sweepable,
      }
    } catch (e) {
      throw toError(e)
    }
  }

  async resolveFeeOption(feeOption: FeeOption): Promise<ResolvedFeeOption> {
    let targetFeeLevel: FeeLevel
    if (isType(FeeOptionCustom, feeOption)) {
      if (feeOption.feeRate !== '0') {
        throw new Error('tron-payments custom fees are unsupported')
      }
      targetFeeLevel = FeeLevel.Custom
    } else {
      targetFeeLevel = feeOption.feeLevel || DEFAULT_FEE_LEVEL
    }
    return {
      targetFeeLevel,
      targetFeeRate: '0',
      targetFeeRateType: FeeRateType.Base,
      feeBase: '0',
      feeMain: '0',
    }
  }

  async createSweepTransaction(
    from: number,
    to: ResolveablePayport,
    options: CreateTransactionOptions = {},
  ): Promise<TronUnsignedTransaction> {
    this.logger.debug('createSweepTransaction', from, to)
    try {
      const { fromAddress, fromIndex, fromPayport, toAddress, toIndex } = await this.resolveFromTo(from, to)
      const { targetFeeLevel, targetFeeRate, targetFeeRateType, feeBase, feeMain } = await this.resolveFeeOption(
        options,
      )
      const feeSun = Number.parseInt(feeBase)
      const { confirmedBalance: balanceTrx } = await this.getBalance(fromPayport)
      const balanceSun = toBaseDenominationNumber(balanceTrx)
      if (!this.canSweepBalance(balanceSun)) {
        throw new Error(
          `Insufficient balance (${balanceTrx}) to sweep with fee of ${feeMain} ` +
            `while maintaining a minimum required balance of ${MIN_BALANCE_TRX}`,
        )
      }
      const amountSun = balanceSun - feeSun - MIN_BALANCE_SUN
      const amountTrx = this.toMainDenomination(amountSun)
      const tx = await this.tronweb.transactionBuilder.sendTrx(toAddress, amountSun, fromAddress)
      return {
        id: tx.txID,
        fromAddress,
        toAddress,
        toExtraId: null,
        fromIndex,
        toIndex,
        amount: amountTrx,
        fee: feeMain,
        targetFeeLevel,
        targetFeeRate,
        targetFeeRateType,
        status: TransactionStatus.Unsigned,
        data: tx,
      }
    } catch (e) {
      throw toError(e)
    }
  }

  async createTransaction(
    from: number,
    to: ResolveablePayport,
    amountTrx: string,
    options: CreateTransactionOptions = {},
  ): Promise<TronUnsignedTransaction> {
    this.logger.debug('createTransaction', from, to, amountTrx)
    try {
      const { fromAddress, fromIndex, fromPayport, toAddress, toIndex } = await this.resolveFromTo(from, to)
      const { targetFeeLevel, targetFeeRate, targetFeeRateType, feeBase, feeMain } = await this.resolveFeeOption(
        options,
      )
      const feeSun = Number.parseInt(feeBase)
      const { confirmedBalance: balanceTrx } = await this.getBalance(fromPayport)
      const balanceSun = toBaseDenominationNumber(balanceTrx)
      const amountSun = toBaseDenominationNumber(amountTrx)
      if (balanceSun - feeSun - MIN_BALANCE_SUN < amountSun) {
        throw new Error(
          `Insufficient balance (${balanceTrx}) to send ${amountTrx} including fee of ${feeMain} ` +
            `while maintaining a minimum required balance of ${MIN_BALANCE_TRX}`,
        )
      }
      const tx = await this.tronweb.transactionBuilder.sendTrx(toAddress, amountSun, fromAddress)
      return {
        id: tx.txID,
        fromAddress,
        toAddress,
        toExtraId: null,
        fromIndex,
        toIndex,
        amount: amountTrx,
        fee: feeMain,
        targetFeeLevel,
        targetFeeRate,
        targetFeeRateType,
        status: TransactionStatus.Unsigned,
        data: tx,
      }
    } catch (e) {
      throw toError(e)
    }
  }

  async signTransaction(unsignedTx: TronUnsignedTransaction): Promise<TronSignedTransaction> {
    try {
      const fromPrivateKey = await this.getPrivateKey(unsignedTx.fromIndex)
      const unsignedRaw = cloneDeep(unsignedTx.data) as TronWebTransaction // tron modifies unsigned object
      const signedTx = await this.tronweb.trx.sign(unsignedRaw, fromPrivateKey)
      return {
        ...unsignedTx,
        status: TransactionStatus.Signed,
        data: signedTx,
      }
    } catch (e) {
      throw toError(e)
    }
  }

  async broadcastTransaction(tx: TronSignedTransaction): Promise<TronBroadcastResult> {
    /*
     * I’ve discovered that tron nodes like to “remember” every transaction you give it.
     * If you try broadcasting an invalid TX the first time you’ll get a `SIGERROR` but
     * every subsequent broadcast gives a `DUP_TRANSACTION_ERROR`. Which is the exact same
     * error you get after rebroadcasting a valid transaction. And to make things worse,
     * if you try to look up the invalid transaction by ID it tells you `Transaction not found`.
     * So in order to actually determine the status of a broadcast the logic becomes:
     * `success status` -> broadcast succeeded
     * `error status` -> broadcast failed
     * `(DUP_TRANSACTION_ERROR && Transaction found)` -> tx already broadcast
     * `(DUP_TRANASCTION_ERROR && Transaction not found)` -> tx was probably invalid? Maybe? Who knows…
     */
    try {
      const status = await this.tronweb.trx.sendRawTransaction(tx.data as TronWebTransaction)
      let success = false
      let rebroadcast = false
      if (status.result || status.code === 'SUCCESS') {
        success = true
      } else {
        try {
          await this.tronweb.trx.getTransaction(tx.id)
          success = true
          rebroadcast = true
        } catch (e) {}
      }
      if (success) {
        return {
          id: tx.id,
          rebroadcast,
        }
      } else {
        let statusCode: string | undefined = status.code
        if (statusCode === 'DUP_TRANSACTION_ERROR') {
          statusCode = 'DUP_TX_BUT_TX_NOT_FOUND_SO_PROBABLY_INVALID_TX_ERROR'
        }
        this.logger.warn(`Tron broadcast tx unsuccessful ${tx.id}`, status)
        throw new Error(`Failed to broadcast transaction: ${statusCode} ${status.message}`)
      }
    } catch (e) {
      throw toError(e)
    }
  }

  async getTransactionInfo(txid: string): Promise<TronTransactionInfo> {
    try {
      const [tx, txInfo, currentBlock] = await Promise.all([
        this.tronweb.trx.getTransaction(txid),
        this.tronweb.trx.getTransactionInfo(txid),
        this.tronweb.trx.getCurrentBlock(),
      ])

      const { amountTrx, fromAddress, toAddress } = this.extractTxFields(tx)

      const contractRet = get(tx, 'ret[0].contractRet')
      const isExecuted = contractRet === 'SUCCESS'

      const block = txInfo.blockNumber || null
      const feeTrx = this.toMainDenomination(txInfo.fee || 0)

      const currentBlockNumber = get(currentBlock, 'block_header.raw_data.number', 0)
      const confirmations = currentBlockNumber && block ? currentBlockNumber - block : 0
      const isConfirmed = confirmations > 0

      const confirmationTimestamp = txInfo.blockTimeStamp ? new Date(txInfo.blockTimeStamp) : null

      let status: TransactionStatus = TransactionStatus.Pending
      if (isConfirmed) {
        if (!isExecuted) {
          status = TransactionStatus.Failed
        }
        status = TransactionStatus.Confirmed
      }

      return {
        id: tx.txID,
        amount: amountTrx,
        toAddress,
        fromAddress,
        toExtraId: null,
        fromIndex: null,
        toIndex: null,
        fee: feeTrx,
        isExecuted,
        isConfirmed,
        confirmations,
        confirmationId: block ? String(block) : null,
        confirmationTimestamp,
        status,
        data: {
          ...tx,
          ...txInfo,
          currentBlock: pick(currentBlock, 'block_header', 'blockID'),
        },
      }
    } catch (e) {
      throw toError(e)
    }
  }

  // HELPERS

  private canSweepBalance(balanceSun: number): boolean {
    return balanceSun > MIN_BALANCE_SUN
  }

  private extractTxFields(tx: TronTransaction) {
    const contractParam = get(tx, 'raw_data.contract[0].parameter.value')
    if (!(contractParam && typeof contractParam.amount === 'number')) {
      throw new Error('Unable to get transaction')
    }

    const amountSun = contractParam.amount || 0
    const amountTrx = this.toMainDenomination(amountSun)
    const toAddress = this.tronweb.address.fromHex(contractParam.to_address)
    const fromAddress = this.tronweb.address.fromHex(contractParam.owner_address)
    return {
      amountTrx,
      amountSun,
      toAddress,
      fromAddress,
    }
  }

  async resolvePayport(payport: ResolveablePayport): Promise<Payport> {
    if (typeof payport === 'number') {
      return this.getPayport(payport)
    } else if (typeof payport === 'string') {
      if (!isValidAddress(payport)) {
        throw new Error(`Invalid TRON address: ${payport}`)
      }
      return { address: payport }
    }
    if (!isValidPayport(payport)) {
      throw new Error(`Invalid TRON payport: ${JSON.stringify(payport)}`)
    }
    return payport
  }

  async resolveFromTo(from: number, to: ResolveablePayport): Promise<FromTo> {
    const fromPayport = await this.getPayport(from)
    const toPayport = await this.resolvePayport(to)
    return {
      fromAddress: fromPayport.address,
      fromIndex: from,
      fromExtraId: fromPayport.extraId,
      fromPayport,
      toAddress: toPayport.address,
      toIndex: typeof to === 'number' ? to : null,
      toExtraId: toPayport.extraId,
      toPayport,
    }
  }
}

export default BaseTronPayments
