/* eslint-disable @typescript-eslint/consistent-type-assertions -- required here */
/* eslint-disable max-lines -- lots of helper functions needed for autofill */
import BigNumber from 'bignumber.js'
import { xAddressToClassicAddress, isValidXAddress } from 'ripple-address-codec'

import { type Client } from '..'
import { ValidationError, XrplError } from '../errors'
import { LoanBroker } from '../models/ledger'
import {
  AccountInfoRequest,
  AccountObjectsRequest,
  LedgerEntryRequest,
} from '../models/methods'
import { Batch, Payment, Transaction } from '../models/transactions'
import { Account } from '../models/transactions/common'
import { xrpToDrops } from '../utils'

import getFeeXrp from './getFeeXrp'

// Expire unconfirmed transactions after 20 ledger versions, approximately 1 minute, by default
const LEDGER_OFFSET = 20
// Sidechains are expected to have network IDs above this.
// Networks with ID above this restricted number are expected specify an accurate NetworkID field
// in every transaction to that chain to prevent replay attacks.
// Mainnet and testnet are exceptions. More context: https://github.com/XRPLF/rippled/pull/4370
const RESTRICTED_NETWORKS = 1024
const REQUIRED_NETWORKID_VERSION = '1.11.0'

/**
 * Determines whether the source rippled version is not later than the target rippled version.
 * Example usage: isNotLaterRippledVersion('1.10.0', '1.11.0') returns true.
 *                isNotLaterRippledVersion('1.10.0', '1.10.0-b1') returns false.
 *
 * @param source -- The source rippled version.
 * @param target -- The target rippled version.
 * @returns True if source is earlier than target, false otherwise.
 */
// eslint-disable-next-line max-lines-per-function, max-statements -- Disable for this helper functions.
function isNotLaterRippledVersion(source: string, target: string): boolean {
  if (source === target) {
    return true
  }
  const sourceDecomp = source.split('.')
  const targetDecomp = target.split('.')
  const sourceMajor = parseInt(sourceDecomp[0], 10)
  const sourceMinor = parseInt(sourceDecomp[1], 10)
  const targetMajor = parseInt(targetDecomp[0], 10)
  const targetMinor = parseInt(targetDecomp[1], 10)
  // Compare major version
  if (sourceMajor !== targetMajor) {
    return sourceMajor < targetMajor
  }
  // Compare minor version
  if (sourceMinor !== targetMinor) {
    return sourceMinor < targetMinor
  }
  const sourcePatch = sourceDecomp[2].split('-')
  const targetPatch = targetDecomp[2].split('-')

  const sourcePatchVersion = parseInt(sourcePatch[0], 10)
  const targetPatchVersion = parseInt(targetPatch[0], 10)

  // Compare patch version
  if (sourcePatchVersion !== targetPatchVersion) {
    return sourcePatchVersion < targetPatchVersion
  }

  // Compare release version
  if (sourcePatch.length !== targetPatch.length) {
    return sourcePatch.length > targetPatch.length
  }

  if (sourcePatch.length === 2) {
    // Compare different release types
    if (!sourcePatch[1][0].startsWith(targetPatch[1][0])) {
      return sourcePatch[1] < targetPatch[1]
    }
    // Compare beta version
    if (sourcePatch[1].startsWith('b')) {
      return (
        parseInt(sourcePatch[1].slice(1), 10) <
        parseInt(targetPatch[1].slice(1), 10)
      )
    }
    // Compare rc version
    return (
      parseInt(sourcePatch[1].slice(2), 10) <
      parseInt(targetPatch[1].slice(2), 10)
    )
  }

  return false
}

/**
 * Determine if the transaction required a networkID to be valid.
 * Transaction needs networkID if later than restricted ID and build version is >= 1.11.0
 *
 * @param client -- The connected client.
 * @returns True if required networkID, false otherwise.
 */
export function txNeedsNetworkID(client: Client): boolean {
  if (
    client.networkID !== undefined &&
    client.networkID > RESTRICTED_NETWORKS
  ) {
    if (
      client.buildVersion &&
      isNotLaterRippledVersion(REQUIRED_NETWORKID_VERSION, client.buildVersion)
    ) {
      return true
    }
  }
  return false
}

interface ClassicAccountAndTag {
  classicAccount: string
  tag: number | false | undefined
}

/**
 * Sets valid addresses for the transaction.
 *
 * @param tx - The transaction object.
 */
export function setValidAddresses(tx: Transaction): void {
  validateAccountAddress(tx, 'Account', 'SourceTag')
  // eslint-disable-next-line @typescript-eslint/dot-notation -- Destination can exist on Transaction
  if (tx['Destination'] != null) {
    validateAccountAddress(tx, 'Destination', 'DestinationTag')
  }

  // DepositPreauth:
  convertToClassicAddress(tx, 'Authorize')
  convertToClassicAddress(tx, 'Unauthorize')
  // EscrowCancel, EscrowFinish:
  convertToClassicAddress(tx, 'Owner')
  // SetRegularKey:
  convertToClassicAddress(tx, 'RegularKey')
}

/**
 * Validates the account address in a transaction object.
 *
 * @param tx - The transaction object.
 * @param accountField - The field name for the account address in the transaction object.
 * @param tagField - The field name for the tag in the transaction object.
 * @throws {ValidationError} If the tag field does not match the tag of the account address.
 */
function validateAccountAddress(
  tx: Transaction,
  accountField: string,
  tagField: string,
): void {
  // if X-address is given, convert it to classic address
  const { classicAccount, tag } = getClassicAccountAndTag(
    tx[accountField] as string,
  )
  // eslint-disable-next-line no-param-reassign -- param reassign is safe
  tx[accountField] = classicAccount

  if (tag != null && tag !== false) {
    if (tx[tagField] && tx[tagField] !== tag) {
      throw new ValidationError(
        `The ${tagField}, if present, must match the tag of the ${accountField} X-address`,
      )
    }
    // eslint-disable-next-line no-param-reassign -- param reassign is safe
    tx[tagField] = tag
  }
}

/**
 * Retrieves the classic account and tag from an account address.
 *
 * @param account - The account address.
 * @param [expectedTag] - The expected tag for the account address.
 * @returns The classic account and tag.
 * @throws {ValidationError} If the address includes a tag that does not match the tag specified in the transaction.
 */
function getClassicAccountAndTag(
  account: string,
  expectedTag?: number,
): ClassicAccountAndTag {
  if (isValidXAddress(account)) {
    const classic = xAddressToClassicAddress(account)
    if (expectedTag != null && classic.tag !== expectedTag) {
      throw new ValidationError(
        'address includes a tag that does not match the tag specified in the transaction',
      )
    }
    return {
      classicAccount: classic.classicAddress,
      tag: classic.tag,
    }
  }
  return {
    classicAccount: account,
    tag: expectedTag,
  }
}

/**
 * Converts the specified field of a transaction object to a classic address format.
 *
 * @param tx - The transaction object.
 * @param fieldName - The name of the field to convert.export
 */
function convertToClassicAddress(tx: Transaction, fieldName: string): void {
  const account = tx[fieldName]
  if (typeof account === 'string') {
    const { classicAccount } = getClassicAccountAndTag(account)
    // eslint-disable-next-line no-param-reassign -- param reassign is safe
    tx[fieldName] = classicAccount
  }
}

// Helper function to get the next valid sequence number for an account.
async function getNextValidSequenceNumber(
  client: Client,
  account: string,
): Promise<number> {
  const request: AccountInfoRequest = {
    command: 'account_info',
    account,
    ledger_index: 'current',
  }
  const data = await client.request(request)
  return data.result.account_data.Sequence
}

/**
 * Sets the next valid sequence number for a transaction.
 *
 * @param client - The client object used for making requests.
 * @param tx - The transaction object for which the sequence number needs to be set.
 * @returns A Promise that resolves when the sequence number is set.
 * @throws {Error} If there is an error retrieving the account information.
 */
export async function setNextValidSequenceNumber(
  client: Client,
  tx: Transaction,
): Promise<void> {
  // eslint-disable-next-line no-param-reassign, require-atomic-updates -- param reassign is safe with no race condition
  tx.Sequence = await getNextValidSequenceNumber(client, tx.Account)
}

/**
 * Fetches the owner reserve fee from the server state using the provided client.
 *
 * @param client - The client object used to make the request.
 * @returns A Promise that resolves to the owner reserve fee as a BigNumber.
 * @throws {Error} Throws an error if the owner reserve fee cannot be fetched.
 */
async function fetchOwnerReserveFee(client: Client): Promise<BigNumber> {
  const response = await client.request({ command: 'server_state' })
  const fee = response.result.state.validated_ledger?.reserve_inc

  if (fee == null) {
    return Promise.reject(new Error('Could not fetch Owner Reserve.'))
  }

  return new BigNumber(fee)
}

/**
 * Fetches the total number of signers for the counterparty of a LoanSet transaction.
 *
 * @param client - The client object used to make the request.
 * @param tx - The transaction object for which the counterparty signers count needs to be fetched.
 * @returns A Promise that resolves to the number of signers for the counterparty.
 * @throws {ValidationError} Throws an error if LoanBrokerID is not provided in the transaction.
 */
async function fetchCounterPartySignersCount(
  client: Client,
  tx: Transaction,
): Promise<number> {
  let counterParty: Account | undefined = tx.Counterparty as Account | undefined
  // Loan Borrower initiated the transaction, Loan Broker is the counterparty.
  if (counterParty == null) {
    if (tx.LoanBrokerID == null) {
      throw new ValidationError(
        'LoanBrokerID is required for LoanSet transaction',
      )
    }
    const resp = (
      await client.request({
        command: 'ledger_entry',
        index: tx.LoanBrokerID,
        ledger_index: 'validated',
      } as LedgerEntryRequest)
    ).result.node as LoanBroker

    counterParty = resp.Owner
  }

  // Now fetch the signer list for the counterparty.
  const signerListRequest: AccountInfoRequest = {
    command: 'account_info',
    account: counterParty,
    ledger_index: 'validated',
    signer_lists: true,
  }

  const signerListResponse = await client.request(signerListRequest)
  const signerList = signerListResponse.result.signer_lists?.[0]
  return signerList?.SignerEntries.length ?? 1
}

/**
 * Calculates the fee per transaction type.
 *
 * @param client - The client object.
 * @param tx - The transaction object.
 * @param [signersCount=0] - The number of signers (default is 0). Only used for multisigning.
 * @returns A promise that returns the fee.
 */
// eslint-disable-next-line max-lines-per-function -- necessary to check for many transaction types.
async function calculateFeePerTransactionType(
  client: Client,
  tx: Transaction,
  signersCount = 0,
): Promise<BigNumber> {
  const netFeeXRP = await getFeeXrp(client)
  const netFeeDrops = xrpToDrops(netFeeXRP)
  let baseFee = new BigNumber(netFeeDrops)

  const isSpecialTxCost = [
    'AccountDelete',
    'AMMCreate',
    'VaultCreate',
  ].includes(tx.TransactionType)

  // EscrowFinish Transaction with Fulfillment
  if (tx.TransactionType === 'EscrowFinish' && tx.Fulfillment != null) {
    const fulfillmentBytesSize: number = Math.ceil(tx.Fulfillment.length / 2)
    // BaseFee × (33 + (Fulfillment size in bytes / 16))
    baseFee = new BigNumber(
      // eslint-disable-next-line @typescript-eslint/no-magic-numbers -- expected use of magic numbers
      scaleValue(netFeeDrops, 33 + fulfillmentBytesSize / 16),
    )
  } else if (isSpecialTxCost) {
    baseFee = await fetchOwnerReserveFee(client)
  } else if (tx.TransactionType === 'Batch') {
    const rawTxFees = await tx.RawTransactions.reduce(
      async (acc, rawTxn) => {
        const resolvedAcc = await acc
        const fee = await calculateFeePerTransactionType(
          client,
          rawTxn.RawTransaction,
        )
        return BigNumber.sum(resolvedAcc, fee)
      },
      Promise.resolve(new BigNumber(0)),
    )
    baseFee = BigNumber.sum(baseFee.times(2), rawTxFees)
  }

  /*
   * Multi-signed Transaction
   * BaseFee × (1 + Number of Signatures Provided)
   */
  if (signersCount > 0) {
    baseFee = BigNumber.sum(baseFee, scaleValue(netFeeDrops, signersCount))
  }

  // LoanSet transactions have additional fees based on the number of signers for the counterparty.
  if (tx.TransactionType === 'LoanSet') {
    const counterPartySignersCount = await fetchCounterPartySignersCount(
      client,
      tx,
    )
    baseFee = BigNumber.sum(
      baseFee,
      scaleValue(netFeeDrops, counterPartySignersCount),
    )
    // eslint-disable-next-line no-console -- necessary to inform users about autofill behavior
    console.warn(
      `For LoanSet transaction the auto calculated Fee accounts for total number of signers the counterparty has to avoid transaction failure.`,
    )
  }

  const maxFeeDrops = xrpToDrops(client.maxFeeXRP)
  const totalFee = isSpecialTxCost
    ? baseFee
    : BigNumber.min(baseFee, maxFeeDrops)

  // Round up baseFee and return it as a string
  return totalFee.dp(0, BigNumber.ROUND_CEIL)
}

/**
 * Calculates the fee per transaction type and sets it in the transaction.
 *
 * @param client - The client object.
 * @param tx - The transaction object.
 * @param [signersCount=0] - The number of signers (default is 0). Only used for multisigning.
 * @returns A promise that resolves with void. Modifies the `tx` parameter to give it the calculated fee.
 */
export async function getTransactionFee(
  client: Client,
  tx: Transaction,
  signersCount = 0,
): Promise<void> {
  const fee = await calculateFeePerTransactionType(client, tx, signersCount)
  // eslint-disable-next-line @typescript-eslint/no-magic-numbers, require-atomic-updates, no-param-reassign -- fine here
  tx.Fee = fee.toString(10)
}

/**
 * Scales the given value by multiplying it with the provided multiplier.
 *
 * @param value - The value to be scaled.
 * @param multiplier - The multiplier to scale the value.
 * @returns The scaled value as a string.
 */
function scaleValue(value, multiplier): string {
  return new BigNumber(value).times(multiplier).toString()
}

/**
 * Sets the latest validated ledger sequence for the transaction.
 *
 * @param client - The client object.
 * @param tx - The transaction object.
 * @returns A promise that resolves with void. Modifies the `tx` parameter setting `LastLedgerSequence`.
 */
export async function setLatestValidatedLedgerSequence(
  client: Client,
  tx: Transaction,
): Promise<void> {
  const ledgerSequence = await client.getLedgerIndex()
  // eslint-disable-next-line no-param-reassign -- param reassign is safe
  tx.LastLedgerSequence = ledgerSequence + LEDGER_OFFSET
}

/**
 * Checks for any blockers that prevent the deletion of an account.
 *
 * @param client - The client object.
 * @param tx - The transaction object.
 * @returns A promise that resolves with void if there are no blockers, or rejects with an XrplError if there are blockers.
 */
export async function checkAccountDeleteBlockers(
  client: Client,
  tx: Transaction,
): Promise<void> {
  const request: AccountObjectsRequest = {
    command: 'account_objects',
    account: tx.Account,
    ledger_index: 'validated',
    deletion_blockers_only: true,
  }
  const response = await client.request(request)
  return new Promise((resolve, reject) => {
    if (response.result.account_objects.length > 0) {
      reject(
        new XrplError(
          `Account ${tx.Account} cannot be deleted; there are Escrows, PayChannels, RippleStates, or Checks associated with the account.`,
          response.result.account_objects,
        ),
      )
    }
    resolve()
  })
}

/**
 * Replaces Amount with DeliverMax if needed.
 *
 * @param tx - The transaction object.
 * @throws ValidationError if Amount and DeliverMax are both provided but do not match.
 */
export function handleDeliverMax(tx: Payment): void {
  if (tx.DeliverMax != null) {
    // If only DeliverMax is provided, use it to populate the Amount field
    // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition, no-param-reassign -- needed here
    tx.Amount ??= tx.DeliverMax

    // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- needed here
    if (tx.Amount != null && tx.Amount !== tx.DeliverMax) {
      throw new ValidationError(
        'PaymentTransaction: Amount and DeliverMax fields must be identical when both are provided',
      )
    }

    // eslint-disable-next-line no-param-reassign -- needed here
    delete tx.DeliverMax
  }
}

/**
 * Autofills all the relevant `x` fields.
 *
 * @param client - The client object.
 * @param tx - The transaction object.
 * @returns A promise that resolves with void if there are no blockers, or rejects with an XrplError if there are blockers.
 */
// eslint-disable-next-line complexity, max-lines-per-function -- needed here, lots to check
export async function autofillBatchTxn(
  client: Client,
  tx: Batch,
): Promise<void> {
  const accountSequences: Record<string, number> = {}

  for (const rawTxn of tx.RawTransactions) {
    const txn = rawTxn.RawTransaction

    // Sequence processing
    if (txn.Sequence == null && txn.TicketSequence == null) {
      if (txn.Account in accountSequences) {
        txn.Sequence = accountSequences[txn.Account]
        accountSequences[txn.Account] += 1
      } else {
        // eslint-disable-next-line no-await-in-loop -- It has to wait
        const nextSequence = await getNextValidSequenceNumber(
          client,
          txn.Account,
        )
        const sequence =
          txn.Account === tx.Account ? nextSequence + 1 : nextSequence
        accountSequences[txn.Account] = sequence + 1
        txn.Sequence = sequence
      }
    }

    if (txn.Fee == null) {
      txn.Fee = '0'
    } else if (txn.Fee !== '0') {
      throw new XrplError('Must have `Fee of "0" in inner Batch transaction.')
    }

    if (txn.SigningPubKey == null) {
      txn.SigningPubKey = ''
    } else if (txn.SigningPubKey !== '') {
      throw new XrplError(
        'Must have `SigningPubKey` of "" in inner Batch transaction.',
      )
    }

    if (txn.TxnSignature != null) {
      throw new XrplError(
        'Must not have `TxnSignature` in inner Batch transaction.',
      )
    }

    if (txn.Signers != null) {
      throw new XrplError('Must not have `Signers` in inner Batch transaction.')
    }

    if (txn.NetworkID == null && txNeedsNetworkID(client)) {
      txn.NetworkID = client.networkID
    }
  }
}
