// SPDX-FileCopyrightText: © 2026 LEDGER SAS
// SPDX-License-Identifier: Apache-2.0

import type { TezosCoinConfig, TezosContext } from '../config'
import type { APIManagerAccount } from '../network/types'
import type { TezosOperationMode } from '../types/model'
import type {
  TransactionIntent,
  TransactionValidation,
} from '@ledgerhq/coin-module-framework/api/types'
import {
  InvalidAddress,
  RecipientRequired,
  NotEnoughBalance,
  AmountRequired,
  InvalidAddressBecauseDestinationIsAlsoSource,
} from '@ledgerhq/coin-module-framework/errors'
import { validateAddress, ValidationResult } from '@taquito/utils'
import { hasManagerKey } from '../network/types'
import { createTzktApi } from '../network/tzkt'
import {
  InvalidAddressBecauseAlreadyDelegated,
  MustDelegateBeforeStaking,
  NotEnoughBalanceToDelegate,
  TezosNotEnoughStaked,
  TezosStakeBlockedByPendingUnstake,
} from '../types/errors'
import {
  computeMaxStakeAmount,
  parseTezosTokenAsset,
  partitionNativeBalance,
  resolveTezosOperationMode,
} from '../utils'
import { estimateFees } from './estimateFees'

function resolveValidationOperationMode(intent: TransactionIntent): TezosOperationMode {
  switch (intent.type) {
    case 'stake':
    case 'unstake':
    case 'finalize_unstake':
      return intent.type
    default:
      return resolveTezosOperationMode(intent.type, intent.asset)
  }
}

function validateStrictlyPositiveAmount(amount: bigint): Error | undefined {
  if (amount === 0n) {
    return new AmountRequired()
  }

  if (amount < 0n) {
    return new NotEnoughBalance()
  }

  return undefined
}

/**
 * Validates basic recipient and amount for send transactions
 */
function validateBasicSendParams(intent: TransactionIntent): Record<string, Error> {
  const errors: Record<string, Error> = {}

  if (intent.type !== 'send') {
    return errors
  }

  if (!intent.recipient) {
    errors.recipient = new RecipientRequired('')
  } else if (validateAddress(intent.recipient) !== ValidationResult.VALID) {
    errors.recipient = new InvalidAddress(undefined, { currencyName: 'Tezos' })
  } else if (intent.sender === intent.recipient) {
    errors.recipient = new InvalidAddressBecauseDestinationIsAlsoSource()
  }

  if (intent.amount === 0n && !intent.useAllAmount) {
    errors.amount = new AmountRequired()
  } else if (intent.amount < 0n) {
    errors.amount = new NotEnoughBalance()
  }

  return errors
}

function validateStakeConstraints(
  intent: TransactionIntent,
  senderInfo: APIManagerAccount
): Record<string, Error> {
  // Staking requires an active delegate. A registered baker (`type: "delegate"`) is its own
  // baker (self-delegated) so it is always eligible, even though tzkt reports no `delegate`
  // field for it; only plain wallets without a delegate must delegate first.
  const isSelfBaker = senderInfo.type === 'delegate'
  if (!isSelfBaker && !senderInfo.delegate?.address) {
    return { amount: new MustDelegateBeforeStaking() }
  }
  if (intent.useAllAmount) {
    return {}
  }
  const amountError = validateStrictlyPositiveAmount(intent.amount)
  return amountError ? { amount: amountError } : {}
}

function validateUnstakeConstraints(
  intent: TransactionIntent,
  senderInfo: APIManagerAccount
): Record<string, Error> {
  const stakedBalance = BigInt(senderInfo.stakedBalance ?? 0)
  if (stakedBalance <= 0n) {
    return { amount: new TezosNotEnoughStaked() }
  }
  if (intent.useAllAmount) {
    return {}
  }
  const amountError = validateStrictlyPositiveAmount(intent.amount)
  if (amountError) {
    return { amount: amountError }
  }
  if (intent.amount > stakedBalance) {
    return { amount: new TezosNotEnoughStaked() }
  }
  return {}
}

function validateFinalizeUnstakeConstraints(finalizable: bigint): Record<string, Error> {
  return finalizable <= 0n ? { amount: new NotEnoughBalance() } : {}
}

function validateTransactionConstraints(
  intent: TransactionIntent,
  senderInfo: APIManagerAccount,
  finalizable: bigint
): Record<string, Error> {
  switch (intent.type) {
    case 'stake':
      return validateStakeConstraints(intent, senderInfo)
    case 'unstake':
      return validateUnstakeConstraints(intent, senderInfo)
    case 'finalize_unstake':
      return validateFinalizeUnstakeConstraints(finalizable)
    default:
      return {}
  }
}

/**
 * Maps Taquito-specific errors to our error types
 */
function mapTaquitoErrors(taquitoError: string, intentType: string): Record<string, Error> {
  const errors: Record<string, Error> = {}

  if (taquitoError.endsWith('balance_too_low') || taquitoError.endsWith('subtraction_underflow')) {
    errors.amount = new NotEnoughBalance()
  } else if (taquitoError.endsWith('staking.too_much_unstaked')) {
    errors.amount = new TezosNotEnoughStaked()
  } else if (taquitoError.endsWith('contract.must_be_delegated_to_stake')) {
    errors.amount = new MustDelegateBeforeStaking()
  } else if (
    taquitoError.endsWith('cannot_stake_with_unfinalizable_unstake_requests_to_another_delegate')
  ) {
    // Changing delegate implicitly unstakes the frozen deposit toward the old delegate; the
    // protocol blocks staking with the new delegate until that unstake finalizes (~4 days).
    errors.amount = new TezosStakeBlockedByPendingUnstake()
  } else if (taquitoError.endsWith('delegate.unchanged')) {
    // Re-delegating (or staking) to the current baker leaves the delegate unchanged; the node
    // rejects it. Surfaces for both `delegate` and `stake` intents as "already delegated".
    errors.recipient = new InvalidAddressBecauseAlreadyDelegated()
  } else if (taquitoError.includes('empty_implicit_contract')) {
    errors.amount =
      intentType === 'stake' ? new NotEnoughBalance() : new NotEnoughBalanceToDelegate()
  } else if (taquitoError.includes('script_rejected')) {
    errors.amount = new NotEnoughBalance()
  } else {
    errors.amount = new Error(taquitoError)
  }

  return errors
}

function calculateNativeSendMaxAmountForUser(
  spendable: bigint,
  estimatedFees: bigint,
  estimatedAmount: bigint | undefined
): { amount: bigint; totalSpent: bigint } {
  const amountFallback = spendable > estimatedFees ? spendable - estimatedFees : 0n
  const hasPositiveEstimatedAmount = estimatedAmount !== undefined && estimatedAmount > 0n
  const amount = hasPositiveEstimatedAmount ? estimatedAmount : amountFallback
  return { amount, totalSpent: amount + estimatedFees }
}

/**
 * Calculates final amounts based on transaction type
 * @param tokenBalanceForSendMax When set, FA2 send-max: full token amount; fees are paid in XTZ only
 */
function calculateAmounts(
  intent: TransactionIntent,
  senderInfo: APIManagerAccount,
  estimatedFees: bigint,
  estimatedAmount: bigint | undefined,
  tokenBalanceForSendMax?: bigint
): { amount: bigint; totalSpent: bigint } {
  if (intent.type === 'stake') {
    if (!intent.useAllAmount) {
      return { amount: intent.amount, totalSpent: intent.amount + estimatedFees }
    }
    if (estimatedAmount !== undefined) {
      return { amount: estimatedAmount, totalSpent: estimatedAmount + estimatedFees }
    }
    // Mirrors estimateFees() stake-max formula for the !revealed short-circuit path.
    const amount = computeMaxStakeAmount(
      BigInt(senderInfo.balance),
      BigInt(senderInfo.stakedBalance ?? 0),
      BigInt(senderInfo.unstakedBalance ?? 0),
      estimatedFees
    )
    return { amount, totalSpent: amount + estimatedFees }
  }

  if (intent.type === 'unstake') {
    const stakedBalance = BigInt(senderInfo.stakedBalance ?? 0)
    const amount = intent.useAllAmount ? stakedBalance : intent.amount
    return { amount, totalSpent: estimatedFees }
  }

  if (intent.type === 'finalize_unstake') {
    return { amount: 0n, totalSpent: estimatedFees }
  }

  if (intent.type === 'send' && intent.useAllAmount) {
    if (tokenBalanceForSendMax !== undefined) {
      return { amount: tokenBalanceForSendMax, totalSpent: estimatedFees }
    }
    const { spendable } = partitionNativeBalance(
      BigInt(senderInfo.balance),
      BigInt(senderInfo.stakedBalance ?? 0),
      BigInt(senderInfo.unstakedBalance ?? 0)
    )
    return calculateNativeSendMaxAmountForUser(spendable, estimatedFees, estimatedAmount)
  }

  // FA1.2/FA2 fixed-amount send: `intent.amount` is in token base units; fees are in XTZ mutez.
  // Never add the token amount to the native coverage check — the units are incompatible.
  if (intent.type === 'send' && parseTezosTokenAsset(intent.asset) !== null) {
    return { amount: intent.amount, totalSpent: estimatedFees }
  }

  const amount = intent.amount
  return { amount, totalSpent: amount + estimatedFees }
}

/**
 * Tezos `balance` includes staked + unstaked-frozen funds that can't pay fees/transfers, so the
 * caller must pass the spendable portion (total minus both), not the raw total.
 */
function validateBalanceCoverage(
  spendableBalance: bigint,
  totalSpent: bigint
): Record<string, Error> {
  const errors: Record<string, Error> = {}
  if (totalSpent > spendableBalance) {
    errors.amount = new NotEnoughBalance()
  }
  return errors
}

async function estimateFeesForIntent(
  context: TezosContext,
  intent: TransactionIntent,
  senderInfo: APIManagerAccount
): Promise<{
  estimatedFees: bigint
  estimatedAmount: bigint | undefined
  errors: Record<string, Error>
}> {
  if (!senderInfo.revealed) {
    return { estimatedFees: 2000n, estimatedAmount: undefined, errors: {} }
  }

  const tezosMode = resolveValidationOperationMode(intent)
  const tokenInfo = tezosMode === 'send_token' ? parseTezosTokenAsset(intent.asset)! : undefined
  const estimation = await estimateFees(context, {
    account: {
      address: intent.sender,
      revealed: senderInfo.revealed,
      balance: BigInt(senderInfo.balance),
      stakedBalance: BigInt(senderInfo.stakedBalance ?? 0),
      unstakedBalance: BigInt(senderInfo.unstakedBalance ?? 0),
      xpub: intent.senderPublicKey ?? senderInfo.publicKey,
    },
    transaction: {
      mode: tezosMode,
      recipient: intent.recipient,
      // finalize_unstake is a parameter-less operation; normalize amount to 0n so
      // fee estimation and the returned validation amount stay consistent.
      amount: intent.type === 'finalize_unstake' ? 0n : intent.amount,
      useAllAmount: !!intent.useAllAmount,
      ...(tokenInfo && {
        contractAddress: tokenInfo.contractAddress,
        tokenId: tokenInfo.tokenId,
      }),
    },
  })

  const errors: Record<string, Error> = {}
  if (estimation.taquitoError) {
    Object.assign(errors, mapTaquitoErrors(estimation.taquitoError, intent.type))
  }

  return {
    estimatedFees: estimation.estimatedFees,
    estimatedAmount: estimation.amount,
    errors,
  }
}

async function fetchTokenBalance(
  config: TezosCoinConfig,
  intent: TransactionIntent
): Promise<bigint | undefined> {
  if (intent.type !== 'send') {
    return undefined
  }

  const tezosMode = resolveTezosOperationMode(intent.type, intent.asset)
  if (tezosMode !== 'send_token') {
    return undefined
  }

  const tokenInfo = parseTezosTokenAsset(intent.asset)
  if (!tokenInfo) {
    return undefined
  }

  const tokenBalances = await createTzktApi(config).getTokensBalances(intent.sender, {
    contractAddress: tokenInfo.contractAddress,
    tokenId: tokenInfo.tokenId,
  })
  const row = tokenBalances.find(
    (b) =>
      b.token.contract.address === tokenInfo.contractAddress &&
      Number(b.token.tokenId) === tokenInfo.tokenId
  )
  return row ? BigInt(row.balance) : 0n
}

// Coverage is checked against live TzKT state (senderInfo) below, not the framework-provided
// balances: the synced spendableBalance can lag between consecutive operations.
export async function validateIntent(
  context: TezosContext,
  intent: TransactionIntent
): Promise<TransactionValidation> {
  const config = await context.config()
  const api = createTzktApi(config)
  const errors: Record<string, Error> = {}
  const warnings: Record<string, Error> = {}
  let estimatedFees: bigint
  let estimatedAmount: bigint | undefined
  let amount: bigint
  let totalSpent: bigint

  const basicErrors = validateBasicSendParams(intent)
  Object.assign(errors, basicErrors)

  if (Object.keys(errors).length > 0) {
    return { errors, warnings, estimatedFees: 0n, amount: 0n, totalSpent: 0n }
  }

  try {
    const senderInfo = await api.getAccountByAddress(intent.sender)
    if (!hasManagerKey(senderInfo)) throw new Error('unexpected account type')

    // Finalizable amount lives on /v1/staking/unstake_requests, not the account
    // endpoint; only `finalize_unstake` validation needs it.
    const finalizable =
      intent.type === 'finalize_unstake'
        ? await api.getUnstakeRequestsFinalizable(intent.sender)
        : 0n

    const constraintErrors = validateTransactionConstraints(intent, senderInfo, finalizable)
    Object.assign(errors, constraintErrors)

    if (Object.keys(errors).length > 0) {
      // Echo intent.amount (not 0n): the desktop AmountField hides the error when amount is 0.
      return { errors, warnings, estimatedFees: 0n, amount: intent.amount, totalSpent: 0n }
    }

    const feeResult = await estimateFeesForIntent(context, intent, senderInfo)
    estimatedFees = feeResult.estimatedFees
    estimatedAmount = feeResult.estimatedAmount
    Object.assign(errors, feeResult.errors)

    // Skip the TzKT call only for fixed-amount sends where errors.amount is already set — the token
    // balance would only be used for coverage, which is also gated on !errors.amount.
    // For send-max we always fetch: calculateAmounts uses tokenBalanceForSendMax as the sent amount,
    // so skipping would cause it to fall back to the native XTZ path and return a wrong unit.
    // The TzKT call is isolated in its own try-catch so that a network failure here does not reach
    // the outer handler (which would wipe the already-computed estimatedFees and add a spurious
    // errors.estimation on top of the real fee-estimation error).
    let tokenBalance: bigint | undefined
    if (errors.amount && !intent.useAllAmount) {
      tokenBalance = undefined
    } else {
      try {
        tokenBalance = await fetchTokenBalance(config, intent)
      } catch {
        tokenBalance = undefined // TzKT unreachable: fall back gracefully, no token coverage check
      }
    }
    // send-max uses the full token balance as the sent amount; fixed-amount only needs it for coverage
    const tokenBalanceForSendMax = intent.useAllAmount ? tokenBalance : undefined

    const amounts = calculateAmounts(
      intent,
      senderInfo,
      estimatedFees,
      estimatedAmount,
      tokenBalanceForSendMax
    )
    amount = amounts.amount
    totalSpent = amounts.totalSpent

    if (intent.type === 'stake' && intent.useAllAmount && amount === 0n && !errors.amount) {
      errors.amount = new NotEnoughBalance()
    }

    const { spendable } = partitionNativeBalance(
      BigInt(senderInfo.balance),
      BigInt(senderInfo.stakedBalance ?? 0),
      BigInt(senderInfo.unstakedBalance ?? 0)
    )
    const balanceErrors = validateBalanceCoverage(spendable, totalSpent)
    Object.assign(errors, balanceErrors)

    // Token balance coverage for fixed-amount token sends.
    // (send-max is always valid by construction: amount is set to tokenBalance above.)
    if (
      !errors.amount &&
      intent.type === 'send' &&
      !intent.useAllAmount &&
      tokenBalance !== undefined
    ) {
      if (amount > tokenBalance) {
        errors.amount = new NotEnoughBalance()
      }
    }
  } catch (e) {
    errors.estimation = e as Error
    estimatedFees = 0n
    amount = intent.amount
    totalSpent = intent.amount
  }

  return { errors, warnings, estimatedFees, amount, totalSpent }
}
