/* eslint-disable no-undef */
import { debug as createDebugLogger } from 'debug'
import { Address, BN, KECCAK256_NULL, toBuffer } from 'ethereumjs-util'
import { Block } from '@ethereumjs/block'
import { ConsensusType } from '@ethereumjs/common'
import {
  AccessList,
  AccessListItem,
  AccessListEIP2930Transaction,
  FeeMarketEIP1559Transaction,
  Transaction,
  TypedTransaction,
  Capability,
} from '@ethereumjs/tx'
import VM from './index'
import Bloom from './bloom'
import { default as EVM, EVMResult } from './evm/evm'
import { short } from './evm/opcodes/util'
import Message from './evm/message'
import TxContext from './evm/txContext'
import { getActivePrecompiles } from './evm/precompiles'
import { EIP2929StateManager } from './state/interface'
import type {
  TxReceipt,
  BaseTxReceipt,
  PreByzantiumTxReceipt,
  PostByzantiumTxReceipt,
} from './types'

const debug = createDebugLogger('vm:tx')
const debugGas = createDebugLogger('vm:tx:gas')

/**
 * Options for the `runTx` method.
 */
export interface RunTxOpts {
  /**
   * The `@ethereumjs/block` the `tx` belongs to.
   * If omitted, a default blank block will be used.
   */
  block?: Block
  /**
   * An `@ethereumjs/tx` to run
   */
  tx: TypedTransaction
  /**
   * If true, skips the nonce check
   */
  skipNonce?: boolean
  /**
   * If true, skips the balance check
   */
  skipBalance?: boolean

  /**
   * If true, skips the validation of the tx's gas limit
   * agains the block's gas limit.
   */
  skipBlockGasLimitValidation?: boolean

  /**
   * If true, adds a generated EIP-2930 access list
   * to the `RunTxResult` returned.
   *
   * Option works with all tx types. EIP-2929 needs to
   * be activated (included in `berlin` HF).
   *
   * Note: if this option is used with a custom {@link StateManager} implementation
   * {@link StateManager.generateAccessList} must be implemented.
   */
  reportAccessList?: boolean

  /**
   * To obtain an accurate tx receipt input the block gas used up until this tx.
   */
  blockGasUsed?: BN

  /**
   * GasPrice proposed by the sequencer expresed in percentage of the tx gas price.
   */
  effectivePercentage?: Number

  /**
   * Virtual Counters Manager instance
   */
  vcm?: any
}

/**
 * Execution result of a transaction
 */
export interface RunTxResult extends EVMResult {
  /**
   * Bloom filter resulted from transaction
   */
  bloom: Bloom

  /**
   * The amount of ether used by this transaction
   */
  amountSpent: BN

  /**
   * The tx receipt
   */
  receipt: TxReceipt

  /**
   * The amount of gas as that was refunded during the transaction (i.e. `gasUsed = totalGasConsumed - gasRefund`)
   */
  gasRefund?: BN

  /**
   * EIP-2930 access list generated for the tx (see `reportAccessList` option)
   */
  accessList?: AccessList
}

export interface AfterTxEvent extends RunTxResult {
  /**
   * The transaction which just got finished
   */
  transaction: TypedTransaction
}

/**
 * @ignore
 */
export default async function runTx(this: VM, opts: RunTxOpts): Promise<RunTxResult> {
  // tx is required
  if (!opts.tx) {
    throw new Error('invalid input, tx is required')
  }

  // create a reasonable default if no block is given
  opts.block = opts.block ?? Block.fromBlockData({}, { common: opts.tx.common })

  if (
    opts.skipBlockGasLimitValidation !== true &&
    opts.block.header.gasLimit.lt(opts.tx.gasLimit)
  ) {
    const msg = _errorMsg('tx has a higher gas limit than the block', this, opts.block, opts.tx)
    throw new Error(msg)
  }

  // Have to cast as `EIP2929StateManager` to access clearWarmedAccounts
  const state = this.stateManager as EIP2929StateManager

  if (opts.reportAccessList && !('generateAccessList' in state)) {
    const msg = _errorMsg(
      'reportAccessList needs a StateManager implementing the generateAccessList() method',
      this,
      opts.block,
      opts.tx
    )
    throw new Error(msg)
  }

  // Ensure we start with a clear warmed accounts Map
  if (this._common.isActivatedEIP(2929)) {
    state.clearWarmedAccounts()
  }

  await state.checkpoint()
  if (this.DEBUG) {
    debug('-'.repeat(100))
    debug(`tx checkpoint`)
  }

  // Typed transaction specific setup tasks
  if (opts.tx.supports(Capability.EIP2718TypedTransaction) && this._common.isActivatedEIP(2718)) {
    // Is it an Access List transaction?
    if (!this._common.isActivatedEIP(2930)) {
      await state.revert()
      const msg = _errorMsg(
        'Cannot run transaction: EIP 2930 is not activated.',
        this,
        opts.block,
        opts.tx
      )
      throw new Error(msg)
    }
    if (opts.reportAccessList && !('generateAccessList' in state)) {
      await state.revert()
      const msg = _errorMsg(
        'StateManager needs to implement generateAccessList() when running with reportAccessList option',
        this,
        opts.block,
        opts.tx
      )
      throw new Error(msg)
    }
    if (opts.tx.supports(Capability.EIP1559FeeMarket) && !this._common.isActivatedEIP(1559)) {
      await state.revert()
      const msg = _errorMsg(
        'Cannot run transaction: EIP 1559 is not activated.',
        this,
        opts.block,
        opts.tx
      )
      throw new Error(msg)
    }

    const castedTx = <AccessListEIP2930Transaction>opts.tx

    castedTx.AccessListJSON.forEach((accessListItem: AccessListItem) => {
      const address = toBuffer(accessListItem.address)
      state.addWarmedAddress(address)
      accessListItem.storageKeys.forEach((storageKey: string) => {
        state.addWarmedStorage(address, toBuffer(storageKey))
      })
    })
  }

  try {
    const result = await _runTx.bind(this)(opts)
    await state.commit()
    if (this.DEBUG) {
      debug(`tx checkpoint committed`)
    }
    if (this._common.isActivatedEIP(2929) && opts.reportAccessList) {
      const { tx } = opts
      // Do not include sender address in access list
      const removed = [tx.getSenderAddress()]
      // Only include to address on present storage slot accesses
      const onlyStorage = tx.to ? [tx.to] : []
      result.accessList = state.generateAccessList!(removed, onlyStorage)
    }
    return result
  } catch (e: any) {
    await state.revert()
    if (this.DEBUG) {
      debug(`tx checkpoint reverted`)
    }
    throw e
  } finally {
    if (this._common.isActivatedEIP(2929)) {
      state.clearWarmedAccounts()
    }
  }
}

async function _runTx(this: VM, opts: RunTxOpts): Promise<RunTxResult> {
  // Have to cast as `EIP2929StateManager` to access the EIP2929 methods
  const state = this.stateManager as EIP2929StateManager
  this.vcm = opts.vcm
  let tx = opts.tx
  const { block } = opts

  if (!block) {
    throw new Error('block required')
  }

  /**
   * The `beforeTx` event
   *
   * @event Event: beforeTx
   * @type {Object}
   * @property {Transaction} tx emits the Transaction that is about to be processed
   */
  await this._emit('beforeTx', tx)

  const caller = tx.getSenderAddress()
  if (this.DEBUG) {
    debug(
      `New tx run hash=${
        opts.tx.isSigned() ? opts.tx.hash().toString('hex') : 'unsigned'
      } sender=${caller}`
    )
  }

  if (this._common.isActivatedEIP(2929)) {
    // Add origin and precompiles to warm addresses
    getActivePrecompiles(this._common).forEach((address: Address) =>
      state.addWarmedAddress(address.buf)
    )
    state.addWarmedAddress(caller.buf)
    if (tx.to) {
      // Note: in case we create a contract, we do this in EVMs `_executeCreate` (this is also correct in inner calls, per the EIP)
      state.addWarmedAddress(tx.to.buf)
    }
  }

  // Validate gas limit against tx base fee (DataFee + TxFee + Creation Fee)
  const txBaseFee = tx.getBaseFee()
  const gasLimit = tx.gasLimit.clone()
  if (gasLimit.lt(txBaseFee)) {
    const msg = _errorMsg('base fee exceeds gas limit', this, block, tx)
    throw new Error(msg)
  }
  gasLimit.isub(txBaseFee)
  if (this.DEBUG) {
    debugGas(`Subtracting base fee (${txBaseFee}) from gasLimit (-> ${gasLimit})`)
  }

  if (this._common.isActivatedEIP(1559)) {
    // EIP-1559 spec:
    // Ensure that the user was willing to at least pay the base fee
    // assert transaction.max_fee_per_gas >= block.base_fee_per_gas
    const maxFeePerGas = 'maxFeePerGas' in tx ? tx.maxFeePerGas : tx.gasPrice
    const baseFeePerGas = block.header.baseFeePerGas!
    if (maxFeePerGas.lt(baseFeePerGas)) {
      const msg = _errorMsg(
        `Transaction's maxFeePerGas (${maxFeePerGas}) is less than the block's baseFeePerGas (${baseFeePerGas})`,
        this,
        block,
        tx
      )
      throw new Error(msg)
    }
  }

  // Check from account's balance and nonce
  let fromAccount = await state.getAccount(caller)
  const { nonce, balance } = fromAccount

  // EIP-3607: Reject transactions from senders with deployed code
  if (this._common.isActivatedEIP(3607) && !fromAccount.codeHash.equals(KECCAK256_NULL)) {
    const msg = _errorMsg('invalid sender address, address is not EOA (EIP-3607)', this, block, tx)
    throw new Error(msg)
  }

  if (!opts.skipBalance) {
    const cost = tx.getUpfrontCost(block.header.baseFeePerGas)
    if (balance.lt(cost)) {
      const msg = _errorMsg(
        `sender doesn't have enough funds to send tx. The upfront cost is: ${cost} and the sender's account (${caller}) only has: ${balance}`,
        this,
        block,
        tx
      )
      throw new Error(msg)
    }
    if (tx.supports(Capability.EIP1559FeeMarket)) {
      // EIP-1559 spec:
      // The signer must be able to afford the transaction
      // `assert balance >= gas_limit * max_fee_per_gas`
      const cost = tx.gasLimit.mul((tx as FeeMarketEIP1559Transaction).maxFeePerGas).add(tx.value)
      if (balance.lt(cost)) {
        const msg = _errorMsg(
          `sender doesn't have enough funds to send tx. The max cost is: ${cost} and the sender's account (${caller}) only has: ${balance}`,
          this,
          block,
          tx
        )
        throw new Error(msg)
      }
    }
  }
  if (!opts.skipNonce) {
    if (!nonce.eq(tx.nonce)) {
      const msg = _errorMsg(
        `the tx doesn't have the correct nonce. account has nonce of: ${nonce} tx has nonce of: ${tx.nonce}`,
        this,
        block,
        tx
      )
      throw new Error(msg)
    }
  }

  let gasPrice
  let inclusionFeePerGas
  // EIP-1559 tx
  if (tx.supports(Capability.EIP1559FeeMarket)) {
    const baseFee = block.header.baseFeePerGas!
    inclusionFeePerGas = BN.min(
      (tx as FeeMarketEIP1559Transaction).maxPriorityFeePerGas,
      (tx as FeeMarketEIP1559Transaction).maxFeePerGas.sub(baseFee)
    )
    gasPrice = inclusionFeePerGas.add(baseFee)
  } else {
    // Have to cast as legacy tx since EIP1559 tx does not have gas price
    // Compute effective gas price and update tx
    if (typeof opts.effectivePercentage === 'undefined') {
      opts.effectivePercentage = 255
    }

    const effectivegasPrice = (<Transaction>tx).gasPrice
      .mul(new BN(Number(opts.effectivePercentage) + 1))
      .div(new BN(256))

    gasPrice = effectivegasPrice
    // Create new Transaction object with updated gas price
    tx = new Transaction({
      ...tx,
      gasPrice,
    })

    if (this._common.isActivatedEIP(1559)) {
      const baseFee = block.header.baseFeePerGas!
      inclusionFeePerGas = (<Transaction>tx).gasPrice.sub(baseFee)
    }
  }

  // Update from account's nonce and balance
  fromAccount.nonce.iaddn(1)
  const txCost = tx.gasLimit.mul(gasPrice)
  fromAccount.balance.isub(txCost)
  await state.putAccount(caller, fromAccount)
  if (this.DEBUG) {
    debug(
      `Update fromAccount (caller) nonce (-> ${fromAccount.nonce}) and balance(-> ${fromAccount.balance})`
    )
  }

  /*
   * Execute message
   */
  const txContext = new TxContext(gasPrice, caller)
  const { value, data, to } = tx
  const message = new Message({
    caller,
    gasLimit,
    to,
    value,
    data,
  })
  const evm = new EVM(this, txContext, block)
  if (this.DEBUG) {
    debug(
      `Running tx=0x${
        tx.isSigned() ? tx.hash().toString('hex') : 'unsigned'
      } with caller=${caller} gasLimit=${gasLimit} to=${
        to ? to.toString() : ''
      } value=${value} data=0x${short(data)}`
    )
  }

  const results = (await evm.executeMessage(message)) as RunTxResult
  if (this.DEBUG) {
    debug('-'.repeat(100))
    debug(
      `Received tx results gasUsed=${results.gasUsed} execResult: [ gasUsed=${
        results.gasUsed
      } exceptionError=${
        results.execResult.exceptionError ? results.execResult.exceptionError.error : ''
      } returnValue=${short(results.execResult.returnValue)} gasRefund=${
        results.execResult.gasRefund
      } ]`
    )
  }

  /*
   * Parse results
   */
  // Generate the bloom for the tx
  results.bloom = txLogsBloom(results.execResult.logs)
  if (this.DEBUG) {
    debug(`Generated tx bloom with logs=${results.execResult.logs?.length}`)
  }

  // Caculate the total gas used
  results.gasUsed.iadd(txBaseFee)
  if (this.DEBUG) {
    debugGas(`tx add baseFee ${txBaseFee} to gasUsed (-> ${results.gasUsed})`)
  }

  // Process any gas refund
  let gasRefund = results.execResult.gasRefund ?? new BN(0)
  const maxRefundQuotient = this._common.param('gasConfig', 'maxRefundQuotient')
  if (!gasRefund.isZero()) {
    const maxRefund = results.gasUsed.divn(maxRefundQuotient)
    gasRefund = BN.min(gasRefund, maxRefund)
    results.gasUsed.isub(gasRefund)
    if (this.DEBUG) {
      debug(`Subtract tx gasRefund (${gasRefund}) from gasUsed (-> ${results.gasUsed})`)
    }
  } else {
    if (this.DEBUG) {
      debug(`No tx gasRefund`)
    }
  }
  results.amountSpent = results.gasUsed.mul(gasPrice)

  // Update sender's balance
  fromAccount = await state.getAccount(caller)
  const actualTxCost = results.gasUsed.mul(gasPrice)
  const txCostDiff = txCost.sub(actualTxCost)
  fromAccount.balance.iadd(txCostDiff)
  await state.putAccount(caller, fromAccount)
  if (this.DEBUG) {
    debug(
      `Refunded txCostDiff (${txCostDiff}) to fromAccount (caller) balance (-> ${fromAccount.balance})`
    )
  }

  // Update miner's balance
  let miner
  if (this._common.consensusType() === ConsensusType.ProofOfAuthority) {
    // Backwards-compatibilty check
    // TODO: can be removed along VM v6 release
    if ('cliqueSigner' in block.header) {
      miner = block.header.cliqueSigner()
    } else {
      miner = Address.zero()
    }
  } else {
    miner = block.header.coinbase
  }

  const minerAccount = await state.getAccount(miner)
  // add the amount spent on gas to the miner's account
  if (this._common.isActivatedEIP(1559)) {
    minerAccount.balance.iadd(results.gasUsed.mul(<BN>inclusionFeePerGas))
  } else {
    minerAccount.balance.iadd(results.amountSpent)
  }

  // Put the miner account into the state. If the balance of the miner account remains zero, note that
  // the state.putAccount function puts this into the "touched" accounts. This will thus be removed when
  // we clean the touched accounts below in case we are in a fork >= SpuriousDragon
  await state.putAccount(miner, minerAccount)
  if (this.DEBUG) {
    debug(`tx update miner account (${miner}) balance (-> ${minerAccount.balance})`)
  }

  /*
   * Cleanup accounts
   */
  if (results.execResult.selfdestruct) {
    const keys = Object.keys(results.execResult.selfdestruct)
    for (const k of keys) {
      const address = new Address(Buffer.from(k, 'hex'))
      await state.deleteAccount(address)
      if (this.DEBUG) {
        debug(`tx selfdestruct on address=${address}`)
      }
    }
  }
  await state.cleanupTouchedAccounts()
  state.clearOriginalStorageCache()

  // Generate the tx receipt
  const cumulativeGasUsed = (opts.blockGasUsed ?? block.header.gasUsed).add(results.gasUsed)
  results.receipt = await generateTxReceipt.bind(this)(tx, results, cumulativeGasUsed)

  /**
   * The `afterTx` event
   *
   * @event Event: afterTx
   * @type {Object}
   * @property {Object} result result of the transaction
   */
  const event: AfterTxEvent = { transaction: tx, ...results }
  await this._emit('afterTx', event)
  if (this.DEBUG) {
    debug(
      `tx run finished hash=${
        opts.tx.isSigned() ? opts.tx.hash().toString('hex') : 'unsigned'
      } sender=${caller}`
    )
  }

  return results
}

/**
 * @method txLogsBloom
 * @private
 */
function txLogsBloom(logs?: any[]): Bloom {
  const bloom = new Bloom()
  if (logs) {
    for (let i = 0; i < logs.length; i++) {
      const log = logs[i]
      // add the address
      bloom.add(log[0])
      // add the topics
      const topics = log[1]
      for (let q = 0; q < topics.length; q++) {
        bloom.add(topics[q])
      }
    }
  }
  return bloom
}

/**
 * Returns the tx receipt.
 * @param this The vm instance
 * @param tx The transaction
 * @param txResult The tx result
 * @param cumulativeGasUsed The gas used in the block including this tx
 */
export async function generateTxReceipt(
  this: VM,
  tx: TypedTransaction,
  txResult: RunTxResult,
  cumulativeGasUsed: BN
): Promise<TxReceipt> {
  const baseReceipt: BaseTxReceipt = {
    gasUsed: cumulativeGasUsed.toArrayLike(Buffer),
    bitvector: txResult.bloom.bitvector,
    logs: txResult.execResult.logs ?? [],
  }

  let receipt
  if (this.DEBUG) {
    debug(
      `Generate tx receipt transactionType=${
        tx.type
      } gasUsed=${cumulativeGasUsed} bitvector=${short(baseReceipt.bitvector)} (${
        baseReceipt.bitvector.length
      } bytes) logs=${baseReceipt.logs.length}`
    )
  }

  if (!tx.supports(Capability.EIP2718TypedTransaction)) {
    // Legacy transaction
    if (this._common.gteHardfork('byzantium')) {
      // Post-Byzantium
      receipt = {
        status: txResult.execResult.exceptionError ? 0 : 1, // Receipts have a 0 as status on error
        ...baseReceipt,
      } as PostByzantiumTxReceipt
    } else {
      // Pre-Byzantium
      const stateRoot = await this.stateManager.getStateRoot(true)
      receipt = {
        stateRoot: stateRoot,
        ...baseReceipt,
      } as PreByzantiumTxReceipt
    }
  } else {
    // Typed EIP-2718 Transaction
    receipt = {
      status: txResult.execResult.exceptionError ? 0 : 1,
      ...baseReceipt,
    } as PostByzantiumTxReceipt
  }

  return receipt
}

/**
 * Internal helper function to create an annotated error message
 *
 * @param msg Base error message
 * @hidden
 */
function _errorMsg(msg: string, vm: VM, block: Block, tx: TypedTransaction) {
  const blockErrorStr = 'errorStr' in block ? block.errorStr() : 'block'
  const txErrorStr = 'errorStr' in tx ? tx.errorStr() : 'tx'

  const errorMsg = `${msg} (${vm.errorStr()} -> ${blockErrorStr} -> ${txErrorStr})`
  return errorMsg
}
