/**
 *
 * Pricecaster Service Utility Library.
 * (c) 2021-22 Randlabs, Inc.
 *
 */

const algosdk = require('algosdk')
const fs = require('fs')
// eslint-disable-next-line camelcase
const tools = require('../tools/app-tools')
const crypto = require('crypto')

const ContractInfo = {
  pricekeeper: {
    approvalProgramFile: 'teal/wormhole/build/pricekeeper-v2-approval.teal',
    clearStateProgramFile: 'teal/wormhole/build/pricekeeper-v2-clear.teal',
    compiledApproval: {
      bytes: undefined,
      hash: undefined
    },
    compiledClearState: {
      bytes: undefined,
      hash: undefined
    },
    appId: 0
  },
  vaaProcessor: {
    approvalProgramFile: 'teal/wormhole/build/vaa-processor-approval.teal',
    clearStateProgramFile: 'teal/wormhole/build/vaa-processor-clear.teal',
    approvalProgramHash: '',
    compiledApproval: {
      bytes: undefined,
      hash: undefined
    },
    compiledClearState: {
      bytes: undefined,
      hash: undefined
    },
    appId: 0
  }
}

// --------------------------------------------------------------------------------------

class PricecasterLib {
  constructor(algodClient, ownerAddr = undefined) {
    this.algodClient = algodClient
    this.ownerAddr = ownerAddr
    this.minFee = 1000
    this.groupTxSet = {}
    this.lsigs = {}
    this.dumpFailedTx = false
    this.dumpFailedTxDirectory = './'

    /** Set the file dumping feature on failed group transactions
     * @param {boolean} f Set to true to enable function, false to disable.
     */
    this.enableDumpFailedTx = function (f) {
      this.dumpFailedTx = f
    }

    /** Set the file dumping feature output directory
     * @param {string} dir The output directory.
     */
    this.setDumpFailedTxDirectory = function (dir) {
      this.dumpFailedTxDirectory = dir
    }

    /** Sets a contract approval program filename
     * @param {string} filename New file name to use.
     */
    this.setApprovalProgramFile = function (contract, filename) {
      ContractInfo[contract].approvalProgramFile = filename
    }

    /** Sets a contract clear state program filename
     * @param {string} filename New file name to use.
     */
    this.setClearStateProgramFile = function (contract, filename) {
      ContractInfo[contract].clearStateProgramFile = filename
    }

    /**
     * Set Application Id for a contract.
     * @param {number} applicationId application id
     * @returns {void}
     */
    this.setAppId = function (contract, applicationId) {
      ContractInfo[contract].appId = applicationId
    }

    /**
     * Get the Application id for a specific contract
     * @returns The requested application Id
     */
    this.getAppId = function (contract) {
      return ContractInfo[contract].appId
    }

    /**
     * Get minimum fee to pay for transactions.
     * @return {Number} minimum transaction fee
     */
    this.minTransactionFee = function () {
      return this.minFee
    }

    /**
     * Internal function.
     * Read application local state related to the account.
     * @param  {String} accountAddr account to retrieve local state
     * @return {Array} an array containing all the {key: value} pairs of the local state
     */
    this.readLocalState = function (accountAddr) {
      return tools.readAppLocalState(this.algodClient, this.appId, accountAddr)
    }

    /**
     * Internal function.
     * Read application global state.
     * @return {Array} an array containing all the {key: value} pairs of the global state
     * @returns {void}
     */
    this.readGlobalState = function () {
      return tools.readAppGlobalState(this.algodClient, this.appId, this.ownerAddr)
    }

    /**
     * Print local state of accountAddr on stdout.
     * @param  {String} accountAddr account to retrieve local state
     * @returns {void}
     */
    this.printLocalState = async function (accountAddr) {
      await tools.printAppLocalState(this.algodClient, this.appId, accountAddr)
    }

    /**
     * Print application global state on stdout.
     * @returns {void}
     */
    this.printGlobalState = async function () {
      await tools.printAppGlobalState(this.algodClient, this.appId, this.ownerAddr)
    }

    /**
     * Internal function.
     * Read application local state variable related to accountAddr.
     * @param  {String} accountAddr account to retrieve local state
     * @param  {String} key variable key to get the value associated
     * @return {String/Number} it returns the value associated to the key that could be an address, a number or a
     * base64 string containing a ByteArray
     */
    this.readLocalStateByKey = function (accountAddr, key) {
      return tools.readAppLocalStateByKey(this.algodClient, this.appId, accountAddr, key)
    }

    /**
     * Internal function.
     * Read application global state variable.
     * @param  {String} key variable key to get the value associated
     * @return {String/Number} it returns the value associated to the key that could be an address,
     * a number or a base64 string containing a ByteArray
     */
    this.readGlobalStateByKey = function (key) {
      return tools.readAppGlobalStateByKey(this.algodClient, this.appId, this.ownerAddr, key)
    }

    /**
     * Compile program that programFilename contains.
     * @param  {String} programFilename filepath to the program to compile
     * @return {String} base64 string containing the compiled program
     */
    this.compileProgram = async function (programBytes) {
      const compileResponse = await this.algodClient.compile(programBytes).do()
      const compiledBytes = new Uint8Array(Buffer.from(compileResponse.result, 'base64'))
      return { bytes: compiledBytes, hash: compileResponse.hash }
    }

    /**
     * Compile clear state program.
     */
    this.compileClearProgram = async function (contract) {
      const program = fs.readFileSync(ContractInfo[contract].clearStateProgramFile, 'utf8')
      ContractInfo[contract].compiledClearState = await this.compileProgram(program)
    }

    /**
     * Compile approval program.
     */
    this.compileApprovalProgram = async function (contract) {
      const program = fs.readFileSync(ContractInfo[contract].approvalProgramFile, 'utf8')
      ContractInfo[contract].compiledApproval = await this.compileProgram(program)
    }

    /**
     * Helper function to retrieve the application id from a createApp transaction response.
     * @param  {Object} txResponse object containig the transactionResponse of the createApp call
     * @return {Number} application id of the created application
     */
    this.appIdFromCreateAppResponse = function (txResponse) {
      return txResponse['application-index']
    }

    /**
     * Create an application based on the default approval and clearState programs or based on the specified files.
     * @param  {String} sender account used to sign the createApp transaction
     * @param  {Function} signCallback callback with prototype signCallback(sender, tx) used to sign transactions
     * @return {String} transaction id of the created application
     */
    this.createApp = async function (sender, contract, localInts, localBytes, globalInts, globalBytes, appArgs, signCallback) {
      const onComplete = algosdk.OnApplicationComplete.NoOpOC

      // get node suggested parameters
      const params = await algodClient.getTransactionParams().do()
      params.fee = this.minFee
      params.flatFee = true

      await this.compileApprovalProgram(contract)
      await this.compileClearProgram(contract)

      // create unsigned transaction
      const txApp = algosdk.makeApplicationCreateTxn(
        sender, params, onComplete,
        ContractInfo[contract].compiledApproval.bytes,
        ContractInfo[contract].compiledClearState.bytes,
        localInts, localBytes, globalInts, globalBytes, appArgs
      )
      const txId = txApp.txID().toString()

      // Sign the transaction
      const txAppSigned = signCallback(sender, txApp)

      // Submit the transaction
      await algodClient.sendRawTransaction(txAppSigned).do()
      return txId
    }

    /**
     * Create the VAA Processor application based on the default approval and clearState programs or based on the specified files.
     * @param  {String} sender account used to sign the createApp transaction
     * @param  {String} gexpTime Guardian key set expiration time
     * @param  {String} gsindex Index of the guardian key set
     * @param  {String} gkeys Guardian keys listed as a single array
     * @param  {Function} signCallback callback with prototype signCallback(sender, tx) used to sign transactions
     * @return {String} transaction id of the created application
     */
    this.createVaaProcessorApp = async function (sender, gexpTime, gsindex, gkeys, signCallback) {
      return await this.createApp(sender, 'vaaProcessor', 0, 0, 5, 20,
        [new Uint8Array(Buffer.from(gkeys, 'hex')),
        algosdk.encodeUint64(parseInt(gexpTime)),
        algosdk.encodeUint64(parseInt(gsindex))], signCallback)
    }

    /**
       * Create the Pricekeeper application based on the default approval and clearState programs or based on the specified files.
       * @param  {String} sender account used to sign the createApp transaction
       * @param  {String} vaaProcessorAppId The application id of the VAA Processor program associated.
       * @param  {Function} signCallback callback with prototype signCallback(sender, tx) used to sign transactions
       * @return {String} transaction id of the created application
       */
    this.createPricekeeperApp = async function (sender, vaaProcessorAppId, signCallback) {
      return await this.createApp(sender, 'pricekeeper', 0, 0, 1, 63,
        [algosdk.encodeUint64(parseInt(vaaProcessorAppId))], signCallback)
    }

    /**
     * Internal function.
     * Call application specifying args and accounts.
     * @param  {String} sender caller address
     * @param  {Array} appArgs array of arguments to pass to application call
     * @param  {Array} appAccounts array of accounts to pass to application call
     * @param  {Function} signCallback callback with prototype signCallback(sender, tx) used to sign transactions
     * @return {String} transaction id of the transaction
     */
    this.callApp = async function (sender, contract, appArgs, appAccounts, signCallback) {
      // get node suggested parameters
      const params = await this.algodClient.getTransactionParams().do()

      params.fee = this.minFee
      params.flatFee = true

      // create unsigned transaction
      const txApp = algosdk.makeApplicationNoOpTxn(sender, params, ContractInfo[contract].appId, appArgs, appAccounts.length === 0 ? undefined : appAccounts)
      const txId = txApp.txID().toString()

      // Sign the transaction
      const txAppSigned = signCallback(sender, txApp)

      // Submit the transaction
      await this.algodClient.sendRawTransaction(txAppSigned).do()

      return txId
    }

    /**
     * ClearState sender. Remove all the sender associated local data.
     * @param  {String} sender account to ClearState
     * @param  {Function} signCallback callback with prototype signCallback(sender, tx) used to sign transactions
     * @return {[String]} transaction id of one of the transactions of the group
     */
    this.clearApp = async function (sender, signCallback, forcedAppId) {
      // get node suggested parameters
      const params = await this.algodClient.getTransactionParams().do()

      params.fee = this.minFee
      params.flatFee = true

      let appId = this.appId
      if (forcedAppId) {
        appId = forcedAppId
      }

      // create unsigned transaction
      const txApp = algosdk.makeApplicationClearStateTxn(sender, params, appId)
      const txId = txApp.txID().toString()

      // Sign the transaction
      const txAppSigned = signCallback(sender, txApp)

      // Submit the transaction
      await this.algodClient.sendRawTransaction(txAppSigned).do()

      return txId
    }

    /**
      * Permanent delete the application.
      * @param  {String} sender owner account
      * @param  {Function} signCallback callback with prototype signCallback(sender, tx) used to sign transactions
      * @param  {Function} applicationId use this application id instead of the one set
      * @return {String}      transaction id of one of the transactions of the group
      */
    this.deleteApp = async function (sender, signCallback, applicationId) {
      // get node suggested parameters
      const params = await this.algodClient.getTransactionParams().do()

      params.fee = this.minFee
      params.flatFee = true

      if (!applicationId) {
        applicationId = this.appId
      }

      // create unsigned transaction
      const txApp = algosdk.makeApplicationDeleteTxn(sender, params, applicationId)
      const txId = txApp.txID().toString()

      // Sign the transaction
      const txAppSigned = signCallback(sender, txApp)

      // Submit the transaction
      await this.algodClient.sendRawTransaction(txAppSigned).do()

      return txId
    }

    /**
     * Helper function to wait until transaction txId is included in a block/round.
     * @param  {String} txId transaction id to wait for
     * @return {VOID} VOID
     */
    this.waitForConfirmation = async function (txId) {
      const status = (await this.algodClient.status().do())
      let lastRound = status['last-round']
      // eslint-disable-next-line no-constant-condition
      while (true) {
        const pendingInfo = await this.algodClient.pendingTransactionInformation(txId).do()
        if (pendingInfo['confirmed-round'] !== null && pendingInfo['confirmed-round'] > 0) {
          // Got the completed Transaction

          return pendingInfo['confirmed-round']
        }
        lastRound += 1
        await this.algodClient.statusAfterBlock(lastRound).do()
      }
    }

    /**
     * Helper function to wait until transaction txId is included in a block/round
     * and returns the transaction response associated to the transaction.
     * @param  {String} txId transaction id to get transaction response
     * @return {Object}      returns an object containing response information
     */
    this.waitForTransactionResponse = async function (txId) {
      // Wait for confirmation
      await this.waitForConfirmation(txId)

      // display results
      return this.algodClient.pendingTransactionInformation(txId).do()
    }

    /**
     * VAA Processor: Sets the stateless logic program hash
     * @param {*} sender Sender account
     * @param {*} hash  The stateless logic program hash
     * @returns Transaction identifier.
     */
    this.setVAAVerifyProgramHash = async function (sender, hash, signCallback) {
      if (!algosdk.isValidAddress(sender)) {
        throw new Error('Invalid sender address: ' + sender)
      }
      const appArgs = []
      appArgs.push(new Uint8Array(Buffer.from('setvphash')),
        algosdk.decodeAddress(hash).publicKey)
      return await this.callApp(sender, 'vaaProcessor', appArgs, [], signCallback)
    }

    /**
     * VAA Processor: Sets the authorized application id for last call
     * @param {*} sender Sender account
     * @param {*} appId  The assigned appId
     * @returns Transaction identifier.
     */
    this.setAuthorizedAppId = async function (sender, appId, signCallback) {
      if (!algosdk.isValidAddress(sender)) {
        throw new Error('Invalid sender address: ' + sender)
      }
      const appArgs = []
      appArgs.push(new Uint8Array(Buffer.from('setauthid')),
        algosdk.encodeUint64(appId))
      return await this.callApp(sender, 'vaaProcessor', appArgs, [], signCallback)
    }

    /**
     * Starts a begin...commit section for commiting grouped transactions.
     */
    this.beginTxGroup = function () {
      const gid = crypto.randomBytes(16).toString('hex')
      this.groupTxSet[gid] = []
      return gid
    }

    /**
       * Adds a transaction to the group.
       * @param {} tx Transaction to add.
       */
    this.addTxToGroup = function (gid, tx) {
      if (this.groupTxSet[gid] === undefined) {
        throw new Error('unknown tx group id')
      }
      this.groupTxSet[gid].push(tx)
    }

    /**
     * @param {*} sender The sender account.
     * @param {function} signCallback The sign callback routine.
     * @returns Transaction id.
     */
    this.commitTxGroup = async function (gid, sender, signCallback) {
      if (this.groupTxSet[gid] === undefined) {
        throw new Error('unknown tx group id')
      }
      algosdk.assignGroupID(this.groupTxSet[gid])

      // Sign the transactions
      const signedTxns = []
      for (const tx of this.groupTxSet[gid]) {
        signedTxns.push(signCallback(sender, tx))
      }

      // Submit the transaction
      const tx = await this.algodClient.sendRawTransaction(signedTxns).do()
      delete this.groupTxSet[gid]
      return tx.txId
    }

    /**
     * @param {*} sender The sender account.
     * @param {*} programBytes Compiled program bytes.
     * @param {*} totalSignatureCount Total signatures present in the VAA.
     * @param {*} sigSubsets An hex string with the signature subsets i..j for logicsig arguments.
     * @param {*} lastTxSender The sender of the last TX in the group.
     * @param {*} signCallback The signing callback function to use in the last TX of the group.
     * @returns Transaction id.
     */
    this.commitVerifyTxGroup = async function (gid, programBytes, totalSignatureCount, sigSubsets, lastTxSender, signCallback) {
      if (this.groupTxSet[gid] === undefined) {
        throw new Error('unknown group id')
      }
      algosdk.assignGroupID(this.groupTxSet[gid])
      const signedGroup = []
      let i = 0
      for (const tx of this.groupTxSet[gid]) {
        // All transactions except last must be signed by stateless code.

        // console.log(`sigSubsets[${i}]: ${sigSubsets[i])

        if (i === this.groupTxSet[gid].length - 1) {
          signedGroup.push(signCallback(lastTxSender, tx))
        } else {
          const lsig = new algosdk.LogicSigAccount(programBytes, [Buffer.from(sigSubsets[i], 'hex'), algosdk.encodeUint64(totalSignatureCount)])
          const stxn = algosdk.signLogicSigTransaction(tx, lsig)
          signedGroup.push(stxn.blob)
        }
        i++
      }

      // Submit the transaction
      let tx
      try {
        tx = await this.algodClient.sendRawTransaction(signedGroup).do()
      } catch (e) {
        if (this.dumpFailedTx) {
          const id = tx ? tx.txId : Date.now().toString()
          const filename = `${this.dumpFailedTxDirectory}/failed-${id}.stxn`
          if (fs.existsSync(filename)) {
            fs.unlinkSync(filename)
          }
          for (let i = 0; i < signedGroup.length; ++i) {
            fs.appendFileSync(filename, signedGroup[i])
          }
        }
        throw e
      }
      delete this.groupTxSet[gid]
      return tx.txId
    }

    /**
     * VAA Processor: Add a verification step to a transaction group.
     * @param {*} sender The sender account (typically the VAA verification stateless program)
     * @param {*} payload The VAA payload.
     * @param {*} gksubset An hex string containing the keys for the guardian subset in this step.
     * @param {*} totalguardians The total number of known guardians.
     */
    this.addVerifyTx = function (gid, sender, params, payload, gksubset, totalguardians) {
      if (this.groupTxSet[gid] === undefined) {
        throw new Error('unknown group id')
      }
      const appArgs = []
      appArgs.push(new Uint8Array(Buffer.from('verify')),
        new Uint8Array(Buffer.from(gksubset.join(''), 'hex')),
        algosdk.encodeUint64(parseInt(totalguardians)))

      const tx = algosdk.makeApplicationNoOpTxn(sender,
        params,
        ContractInfo.vaaProcessor.appId,
        appArgs, undefined, undefined, undefined,
        new Uint8Array(payload))
      this.groupTxSet[gid].push(tx)

      return tx.txID()
    }

    /**
     * Pricekeeper-V2: Add store price transaction to TX Group.
     * @param {*} sender The sender account (typically the VAA verification stateless program)
     * @param {*} sym The symbol identifying the product  to store price for.
     * @param {*} payload The VAA payload.
     */
    this.addPriceStoreTx = function (gid, sender, params, sym, payload) {
      if (this.groupTxSet[gid] === undefined) {
        throw new Error('unknown group id')
      }
      const appArgs = []
      appArgs.push(new Uint8Array(Buffer.from('store')),
        new Uint8Array(Buffer.from(sym)),
        new Uint8Array(payload))

      const tx = algosdk.makeApplicationNoOpTxn(sender,
        params,
        ContractInfo.pricekeeper.appId,
        appArgs)
      this.groupTxSet[gid].push(tx)

      return tx.txID()
    }
  }
}

module.exports = {
  PricecasterLib
}
