import type { Chain, Platform, WalletInit } from '@web3-onboard/common'
import type { StaticJsonRpcProvider } from '@ethersproject/providers'
import type { ETHAccountPath } from '@shapeshiftoss/hdwallet-core'
import type { KeepKeyHDWallet } from '@shapeshiftoss/hdwallet-keepkey'

import type {
  ScanAccountsOptions,
  Account,
  Asset
} from '@web3-onboard/hw-common'

const DEFAULT_PATH = `m/44'/60'/0'/0/0`

const DEFAULT_BASE_PATHS = [
  {
    label: 'Ethereum Mainnet',
    value: DEFAULT_PATH
  }
]

const assets = [
  {
    label: 'ETH'
  }
]

const ERROR_BUSY: ErrorCode = 'busy'
const ERROR_PAIRING: ErrorCode = 'pairing'

const errorMessages = {
  [ERROR_BUSY]: `Your KeepKey is currently connected to another application.
  Please close any other browser tabs or applications that may be connected to your device and try again.`,
  [ERROR_PAIRING]:
    'There was an error pairing the device. Please disconnect and reconnect the device and try again.'
}

type ErrorCode = 'busy' | 'pairing'

function keepkey({
  filter,
  containerElement,
  consecutiveEmptyAccountThreshold
}: {
  filter?: Platform[]
  containerElement?: string
  /**
   * A number that defines the amount of consecutive empty addresses displayed
   * within the Account Select modal. Default is 5
   */
  consecutiveEmptyAccountThreshold?: number
} = {}): WalletInit {
  const getIcon = async () => (await import('./icon.js')).default

  return ({ device }) => {
    let accounts: Account[] | undefined

    const filtered =
      Array.isArray(filter) &&
      (filter.includes(device.type) || filter.includes(device.os.name))

    if (filtered) return null

    return {
      label: 'KeepKey',
      getIcon,
      getInterface: async ({ EventEmitter, chains }) => {
        const { WebUSBKeepKeyAdapter } = await import(
          '@shapeshiftoss/hdwallet-keepkey-webusb'
        )

        const {
          Keyring,
          Events,
          bip32ToAddressNList,
          addressNListToBIP32,
          HDWalletErrorType
        } = await import('@shapeshiftoss/hdwallet-core')

        const { createEIP1193Provider, ProviderRpcError } = await import(
          '@web3-onboard/common'
        )

        const { accountSelect, entryModal } = await import(
          '@web3-onboard/hw-common'
        )

        const { bigNumberFieldsToStrings, getHardwareWalletProvider } =
          await import('@web3-onboard/hw-common')

        const { utils } = await import('ethers')

        const { StaticJsonRpcProvider } = await import(
          '@ethersproject/providers'
        )
        const ethUtil = await import('ethereumjs-util')

        const keyring = new Keyring()
        const keepKeyAdapter = WebUSBKeepKeyAdapter.useKeyring(keyring)
        const eventEmitter = new EventEmitter()
        const consecutiveEmptyAccounts = consecutiveEmptyAccountThreshold || 5

        let keepKeyWallet: KeepKeyHDWallet
        let currentChain: Chain = chains[0]

        keyring.on(['*', '*', Events.DISCONNECT], async () => {
          eventEmitter.emit('accountsChanged', [])
        })

        // If the wallet asks for a PIN, open the PIN modal
        keyring.on(['*', '*', Events.PIN_REQUEST], () => {
          entryModal(
            'pin',
            val => keepKeyWallet.sendPin(val),
            () => keepKeyWallet.cancel()
          )
        })

        // If the wallet asks for a PIN, open the PIN modal
        keyring.on(['*', '*', Events.PASSPHRASE_REQUEST], () => {
          entryModal(
            'passphrase',
            val => keepKeyWallet.sendPassphrase(val),
            () => keepKeyWallet.cancel()
          )
        })

        const getAccountIdx = (derivationPath: string) => {
          // Get the account index from the derivation path
          const { accountIdx } = keepKeyWallet.describePath({
            path: bip32ToAddressNList(derivationPath),
            coin: 'Ethereum'
          })

          if (accountIdx === undefined)
            throw new Error(
              `Could not derive account from path: ${derivationPath}`
            )

          return accountIdx
        }

        const getPaths = (accountIdx: number): ETHAccountPath => {
          // Retrieve the array form of the derivation path for a given account index
          const [paths] = keepKeyWallet.ethGetAccountPaths({
            coin: 'Ethereum',
            accountIdx
          })

          return paths
        }

        const getAccount = async ({
          accountIdx,
          provider,
          asset
        }: {
          accountIdx: number
          provider: StaticJsonRpcProvider
          asset: Asset
        }) => {
          const paths = getPaths(accountIdx)

          // Retrieve the address associated with the given account index
          const address = await keepKeyWallet.ethGetAddress({
            addressNList: paths.addressNList,
            showDisplay: false
          })

          const balance = await provider.getBalance(address)

          return {
            derivationPath: addressNListToBIP32(paths.addressNList),
            address,
            balance: {
              asset: asset.label,
              value: balance
            }
          }
        }

        const getAllAccounts = async ({
          derivationPath,
          asset,
          provider
        }: {
          derivationPath: string
          asset: Asset
          provider: StaticJsonRpcProvider
        }) => {
          try {
            let index = getAccountIdx(derivationPath)
            let zeroBalanceAccounts = 0
            const accounts = []

            // Iterates until a 0 balance account is found
            // Then adds 4 more 0 balance accounts to the array
            while (zeroBalanceAccounts < consecutiveEmptyAccounts) {
              const acc = await getAccount({
                accountIdx: index,
                provider,
                asset
              })

              if (
                acc &&
                acc.balance &&
                acc.balance.value &&
                acc.balance.value.isZero()
              ) {
                zeroBalanceAccounts++
                accounts.push(acc)
              } else {
                accounts.push(acc)
                // Reset the number of 0 balance accounts
                zeroBalanceAccounts = 0
              }

              index++
            }

            return accounts
          } catch (error) {
            throw new Error(
              (error as { message: { message: string } }).message.message
            )
          }
        }
        let ethersProvider: StaticJsonRpcProvider
        const scanAccounts = async ({
          derivationPath,
          chainId,
          asset
        }: ScanAccountsOptions): Promise<Account[]> => {
          if (!keepKeyWallet)
            throw new Error('Device must be connected before scanning accounts')
          currentChain = chains.find(({ id }) => id === chainId) || currentChain
          ethersProvider = new StaticJsonRpcProvider(currentChain.rpcUrl)

          // Checks to see if this is a custom derivation path
          // If it is then just return the single account
          if (
            !DEFAULT_BASE_PATHS.find(({ value }) => value === derivationPath)
          ) {
            try {
              const accountIdx = getAccountIdx(derivationPath)
              const account = await getAccount({
                accountIdx,
                provider: ethersProvider,
                asset
              })

              return [account]
            } catch (error) {
              throw new Error('Invalid derivation path')
            }
          }

          return getAllAccounts({
            derivationPath,
            asset,
            provider: ethersProvider
          })
        }

        const getAccounts = async () => {
          accounts = await accountSelect({
            basePaths: DEFAULT_BASE_PATHS,
            assets,
            chains,
            scanAccounts,
            containerElement
          })
          if (!accounts) throw new Error('No accounts were found')
          if (accounts.length) {
            eventEmitter.emit('accountsChanged', [accounts[0].address])
          }

          return accounts
        }

        const signMessage = async (address: string, message: string) => {
          if (
            !accounts ||
            !Array.isArray(accounts) ||
            !(accounts.length && accounts.length > 0)
          )
            throw new Error(
              'No account selected. Must call eth_requestAccounts first.'
            )

          const account =
            accounts.find(account => account.address === address) || accounts[0]

          const { derivationPath } = account
          const accountIdx = getAccountIdx(derivationPath)
          const { addressNList } = getPaths(accountIdx)

          const { signature } = await keepKeyWallet.ethSignMessage({
            addressNList,
            message:
              message.slice(0, 2) === '0x'
                ? // @ts-ignore - commonjs weirdness
                  (ethUtil.default || ethUtil)
                    .toBuffer(message)
                    .toString('utf8')
                : message
          })

          return signature
        }

        const keepKeyProvider = getHardwareWalletProvider(
          () => currentChain.rpcUrl || ''
        )

        const provider = createEIP1193Provider(keepKeyProvider, {
          eth_requestAccounts: async () => {
            if (keepKeyWallet && typeof keepKeyWallet.cancel === 'function') {
              // cancel any current actions on device
              keepKeyWallet.cancel()
            }

            try {
              keepKeyWallet =
                (await keepKeyAdapter.pairDevice()) as KeepKeyHDWallet
            } catch (error) {
              const { name } = error as { name: string }
              // This error indicates that the keepkey is paired with another app
              if (name === HDWalletErrorType.ConflictingApp) {
                throw new ProviderRpcError({
                  code: 4001,
                  message: errorMessages[ERROR_BUSY]
                })

                // This error indicates that for some reason we can't claim the usb device
              } else if (name === HDWalletErrorType.WebUSBCouldNotPair) {
                throw new ProviderRpcError({
                  code: 4001,
                  message: errorMessages[ERROR_PAIRING]
                })
              }
            }

            // Triggers the account select modal if no accounts have been selected
            const accounts = await getAccounts()

            if (!accounts || !Array.isArray(accounts)) {
              throw new Error('No accounts were returned from Keepkey device')
            }
            if (!accounts.length) {
              throw new ProviderRpcError({
                code: 4001,
                message: 'User rejected the request.'
              })
            }
            if (!accounts[0].hasOwnProperty('address')) {
              throw new Error(
                'The account returned does not have a required address field'
              )
            }

            return [accounts[0].address]
          },
          eth_selectAccounts: async () => {
            const accounts = await getAccounts()
            return accounts.map(({ address }) => address)
          },
          eth_accounts: async () => {
            if (!accounts || !Array.isArray(accounts)) {
              throw new Error('No accounts were returned from Keepkey device')
            }
            return accounts[0].hasOwnProperty('address')
              ? [accounts[0].address]
              : []
          },
          eth_chainId: async () => {
            return currentChain && currentChain.id != undefined
              ? currentChain.id
              : '0x0'
          },
          eth_signTransaction: async ({ params: [transactionObject] }) => {
            if (!accounts || !Array.isArray(accounts) || !accounts.length)
              throw new Error(
                'No account selected. Must call eth_requestAccounts first.'
              )

            // Per the code above if accounts is empty or undefined then this line of code won't execute
            // ∴ account must be defined here which is why it is cast without the 'undefined' type
            const account =
              !transactionObject || !transactionObject.hasOwnProperty('from')
                ? accounts[0]
                : (accounts.find(
                    account =>
                      account.address.toLocaleLowerCase() ===
                      transactionObject.from.toLocaleLowerCase()
                  ) as Account)

            const { derivationPath, address } = account
            const addressNList = bip32ToAddressNList(derivationPath)

            const signer = ethersProvider.getSigner(address)

            transactionObject.gasLimit =
              transactionObject.gas || transactionObject.gasLimit

            // 'gas' is an invalid property for the TransactionRequest type
            delete transactionObject.gas

            transactionObject.gasLimit = undefined

            let populatedTransaction = await signer.populateTransaction(
              transactionObject
            )

            const {
              to,
              value,
              nonce,
              gasLimit,
              gasPrice,
              maxFeePerGas,
              maxPriorityFeePerGas,
              data
            } = bigNumberFieldsToStrings(populatedTransaction)

            const gasData = gasPrice
              ? {
                  gasPrice
                }
              : {
                  maxFeePerGas,
                  maxPriorityFeePerGas
                }

            const txn = {
              addressNList,
              chainId: parseInt(currentChain.id),
              to: to || '',
              value: value || '',
              nonce: utils.hexValue(nonce),
              gasLimit: gasLimit || '0x0',
              data: (data || '').toString(),
              ...gasData
            }

            let serialized
            try {
              ;({ serialized } = await keepKeyWallet.ethSignTx(txn))
            } catch (error: any) {
              if (error.message && error.message.message) {
                throw new Error(error.message.message)
              } else {
                throw new Error(error)
              }
            }
            return serialized
          },
          eth_sendTransaction: async ({ baseRequest, params }) => {
            const signedTx = await provider.request({
              method: 'eth_signTransaction',
              params
            })

            const transactionHash = await baseRequest({
              method: 'eth_sendRawTransaction',
              params: [signedTx]
            })

            return transactionHash as string
          },
          eth_sign: async ({ params: [address, message] }) =>
            signMessage(address, message),
          personal_sign: async ({ params: [message, address] }) =>
            signMessage(address, message),
          eth_signTypedData: null,
          wallet_switchEthereumChain: async ({ params: [{ chainId }] }) => {
            currentChain =
              chains.find(({ id }) => id === chainId) || currentChain

            if (!currentChain)
              throw new Error('chain must be set before switching')

            eventEmitter.emit('chainChanged', currentChain.id)
            return null
          },
          wallet_addEthereumChain: null
        })

        provider.on = eventEmitter.on.bind(eventEmitter)

        return {
          provider,
          instance: {
            selectAccount: getAccounts
          }
        }
      }
    }
  }
}

export default keepkey
