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

import type { Operation, Page } from '@ledgerhq/coin-module-framework/api/types'
import type { CacheRes } from '@ledgerhq/live-network/cache'
import { LedgerAPI4xx, LedgerAPI5xx, NetworkDown } from '@ledgerhq/errors'
import { makeLRUCache } from '@ledgerhq/live-network/cache'
import { log } from '@ledgerhq/logs'
import {
  BASE_FEE,
  Horizon,
  MuxedAccount,
  NetworkError,
  Networks,
  NotFoundError,
  Transaction as StellarSdkTransaction,
  StrKey,
  xdr,
} from '@stellar/stellar-sdk'
import { BigNumber } from 'bignumber.js'
import coinConfig from '../config'
import { parseAPIValue } from '../logic/common'
import { patchHermesTypedArraysIfNeeded, unpatchHermesTypedArrays } from '../polyfill'
import {
  type BalanceAsset,
  type NetworkInfo,
  type RawOperation,
  type Signer,
  NetworkCongestionLevel,
} from '../types'
import { StellarBroadcastFailedError, type StellarDecodedResultXdr } from '../types/errors'
import {
  documentationSummaryFromHorizonBody,
  getHorizonErrorBody,
  type HorizonErrorBody,
} from './horizonErrorBody'
import { throwHorizonLedgerOrOperationsError } from './horizonLedgerErrors'
import { getReservedBalance, rawOperationsToOperations } from './serialization'

const FALLBACK_BASE_FEE = 100
const TRESHOLD_LOW = 0.5
const TRESHOLD_MEDIUM = 0.75
const FETCH_LIMIT = 100

const STELLAR_TX_RESULT_CODES_DOC_URL =
  'https://developers.stellar.org/docs/data/apis/horizon/api-reference/errors/result-codes/transactions'

function decodeTransactionResultFields(resultXdrBase64: string | undefined):
  | {
      feeChargedStroops?: string
      resultXdrSwitchName?: string
      decodedResultXdr: StellarDecodedResultXdr
    }
  | undefined {
  if (!resultXdrBase64) {
    return undefined
  }
  try {
    const tr = xdr.TransactionResult.fromXDR(resultXdrBase64, 'base64')
    const feeChargedStroops = tr.feeCharged().toString()
    const resultXdrSwitchName = tr.result().switch().name
    const decodedResultXdr: StellarDecodedResultXdr = {
      feeChargedStroops,
      resultSwitch: resultXdrSwitchName,
    }
    return { feeChargedStroops, resultXdrSwitchName, decodedResultXdr }
  } catch {
    return {
      decodedResultXdr: { decodeFailed: true, rawResultXdrBase64: resultXdrBase64 },
    }
  }
}

function makeBroadcastFailedError(body: HorizonErrorBody, cause: Error): Error {
  const extras = body.extras
  const horizonTransactionCode = extras?.result_codes?.transaction ?? ''
  const horizonOperationCodes = extras?.result_codes?.operations
  const documentationSummary = documentationSummaryFromHorizonBody(body)
  const decodedResult = decodeTransactionResultFields(extras?.result_xdr)
  const feeChargedStroops = decodedResult?.feeChargedStroops
  const resultXdrSwitchName = decodedResult?.resultXdrSwitchName
  const decodedResultXdr = decodedResult?.decodedResultXdr
  const envelopeXdr = extras?.envelope_xdr ?? ''
  const message =
    body.detail || body.title || `Transaction submission failed (${body.status ?? 'unknown'}).`

  return new StellarBroadcastFailedError(
    message,
    {
      documentationSummary,
      horizonTransactionCode,
      horizonOperationCodes,
      resultXdrSwitchName,
      feeChargedStroops,
      stellarDocUrl: STELLAR_TX_RESULT_CODES_DOC_URL,
      decodedResultXdr,
      envelopeXdr,
    },
    { cause }
  )
}

// Horizon client instance is cached to avoid costly rebuild at every request
// Watch out: cache key is the URL, coin module can be instantiated several times with different URLs
const servers = new Map<string, Horizon.Server>()
function getServer(): Horizon.Server {
  const url = coinConfig.getCoinConfig().explorer.url
  let server = servers.get(url)
  if (server === undefined) {
    server = new Horizon.Server(url)
    servers.set(url, server)
  }
  return server
}

// Tracks request start times for response-duration logging.
// WeakMap avoids mutating the feaxios request config (whose type doesn't include `metadata`).
const requestStartTimes = new WeakMap<object, number>()

let interceptorsRegistered = false

/**
 * Register logging and URL-fix interceptors on Horizon.AxiosClient.
 * Must be called once after coinConfig is initialised (i.e. inside createApi).
 *
 * Logging is gated on coinConfig.enableNetworkLogs (driven by the ENABLE_NETWORK_LOGS env var).
 * Horizon.AxiosClient uses feaxios internally, so we type the callbacks via the inferred
 * InterceptorManager types rather than importing live-network's axios-typed interceptors.
 */
export function registerHorizonInterceptors(): void {
  if (interceptorsRegistered) return
  interceptorsRegistered = true

  Horizon.AxiosClient.interceptors.request.use((config) => {
    let enableNetworkLogs = false
    try {
      enableNetworkLogs = !!coinConfig.getCoinConfig().enableNetworkLogs
    } catch {
      return config
    }
    if (enableNetworkLogs) {
      const { baseURL, url, method = '', data } = config
      log('network', `${method} ${baseURL ?? ''}${url}`, { data })
      requestStartTimes.set(config, Date.now())
    }
    return config
  })

  Horizon.AxiosClient.interceptors.response.use((response) => {
    let enableNetworkLogs = false
    try {
      enableNetworkLogs = !!coinConfig.getCoinConfig().enableNetworkLogs
    } catch {
      // coinConfig not initialised on this instance; skip logging and leave URLs untouched
    }
    if (enableNetworkLogs) {
      // response.config is typed as `any` in stellar-sdk's HttpClientResponse
      const startTime = requestStartTimes.get(response.config) ?? 0
      requestStartTimes.delete(response.config)
      log(
        'network-success',
        `${response.status} ${response.config?.method ?? ''} ${response.config?.baseURL ?? ''}${response.config?.url ?? ''} (${(Date.now() - startTime).toFixed(0)}ms)`,
        { data: response.data }
      )
    }

    // FIXME: workaround for the Stellar SDK not using the correct URL: the "next" URL
    // included in server responses points to the node itself instead of our reverse proxy...
    // (https://github.com/stellar/js-stellar-sdk/issues/637)
    const next_href = response?.data?._links?.next?.href
    if (next_href) {
      response.data._links.next.href = useConfigHostAndProtocol(next_href)
    }
    response?.data?._embedded?.records?.forEach((r: any) => {
      const href = r.transaction?._links?.ledger?.href
      if (href) r.transaction._links.ledger.href = useConfigHostAndProtocol(href)
    })

    return response
  })
}

// It replaces the host and the protocol of the URL returned with the original ones.
export function useConfigHostAndProtocol(url: string): string {
  const originalUrl = new URL(coinConfig.getCoinConfig().explorer.url)
  // URL.protocol setter silently fails when changing between special (https://) and
  // non-special (injected://) schemes, so reconstruct via string replacement instead.
  return url.replace(/^[^:]+:\/\/[^/]+/, `${originalUrl.protocol}//${originalUrl.host}`)
}

const getMinimumBalance = (account: Horizon.ServerApi.AccountRecord): BigNumber => {
  return parseAPIValue(getReservedBalance(account).toString())
}

export async function getAccountSpendableBalance(
  balance: BigNumber,
  account: Horizon.ServerApi.AccountRecord
): Promise<BigNumber> {
  const minimumBalance = getMinimumBalance(account)
  const { recommendedFee } = await fetchBaseFee()
  return BigNumber.max(balance.minus(minimumBalance).minus(recommendedFee), 0)
}

export async function fetchBaseFee(): Promise<{
  baseFee: number
  recommendedFee: number
  networkCongestionLevel: NetworkCongestionLevel
}> {
  // For tests
  if (coinConfig.getCoinConfig().useStaticFees) {
    return {
      baseFee: 100,
      recommendedFee: 100,
      networkCongestionLevel: NetworkCongestionLevel.LOW,
    }
  }

  const baseFee = new BigNumber(BASE_FEE).toNumber() || FALLBACK_BASE_FEE
  let recommendedFee = baseFee
  let networkCongestionLevel = NetworkCongestionLevel.MEDIUM

  try {
    const feeStats = await getServer().feeStats()
    const ledgerCapacityUsage = feeStats.ledger_capacity_usage
    recommendedFee = new BigNumber(feeStats.fee_charged.mode).toNumber()

    if (
      new BigNumber(ledgerCapacityUsage).toNumber() > TRESHOLD_LOW &&
      new BigNumber(ledgerCapacityUsage).toNumber() <= TRESHOLD_MEDIUM
    ) {
      networkCongestionLevel = NetworkCongestionLevel.MEDIUM
    } else if (new BigNumber(ledgerCapacityUsage).toNumber() > TRESHOLD_MEDIUM) {
      networkCongestionLevel = NetworkCongestionLevel.HIGH
    } else {
      networkCongestionLevel = NetworkCongestionLevel.LOW
    }
  } catch {
    // do nothing, will use defaults
  }

  return {
    baseFee,
    recommendedFee,
    networkCongestionLevel,
  }
}

/**
 * Get all account-related data
 *
 * @async
 * @param {string} addr
 */
export async function fetchAccount(addr: string): Promise<{
  blockHeight: number
  balance: BigNumber
  spendableBalance: BigNumber
  assets: BalanceAsset[]
}> {
  let account: Horizon.ServerApi.AccountRecord = {} as Horizon.ServerApi.AccountRecord
  let assets: BalanceAsset[] = []
  let balance = '0'

  try {
    account = await getServer().accounts().accountId(addr).call()
    balance =
      account.balances?.find((balance) => {
        return balance.asset_type === 'native'
      })?.balance || '0'
    // Getting all non-native (XLM) assets on the account
    assets = account.balances?.filter((balance) => {
      return balance.asset_type !== 'native'
    }) as BalanceAsset[]
  } catch {
    balance = '0'
  }

  const formattedBalance = parseAPIValue(balance)

  const spendableBalance = await getAccountSpendableBalance(formattedBalance, account)

  return {
    blockHeight: account.sequence ? new BigNumber(account.sequence).toNumber() : 0,
    balance: formattedBalance,
    spendableBalance,
    assets,
  }
}

/**
 * Fetch operations for a single account from indexer
 *
 * @param {string} accountId
 * @param {string} addr
 * @param {string} order - "desc" or "asc" order of returned records
 * @param {string} cursor - point to start fetching records
 * @param {number} maxOperations - maximum number of operations to return, stops fetching after reaching this threshold
 *
 * @return {Operation[]}
 */
export async function fetchAllOperations(
  accountId: string,
  addr: string,
  order: 'asc' | 'desc',
  cursor: string = '',
  maxOperations?: number
): Promise<Operation[]> {
  if (!addr) {
    return []
  }

  const limit = coinConfig.getCoinConfig().explorer.fetchLimit ?? FETCH_LIMIT
  let operations: Operation[] = []
  let fetchedOpsCount = limit

  try {
    let rawOperations = await getServer()
      .operations()
      .forAccount(addr)
      .limit(limit)
      .order(order)
      .cursor(cursor)
      .includeFailed(true)
      .join('transactions')
      .call()

    if (!rawOperations || !rawOperations.records.length) {
      return []
    }

    operations = operations.concat(
      await rawOperationsToOperations(rawOperations.records as RawOperation[], addr, accountId, 0)
    )

    while (rawOperations.records.length > 0) {
      if (maxOperations && fetchedOpsCount >= maxOperations) {
        break
      }
      fetchedOpsCount += limit

      rawOperations = await rawOperations.next()
      operations = operations.concat(
        await rawOperationsToOperations(rawOperations.records as RawOperation[], addr, accountId, 0)
      )
    }

    return operations
  } catch (e: unknown) {
    // FIXME: terrible hacks, because Stellar SDK fails to cast network failures to typed errors in react-native...
    // (https://github.com/stellar/js-stellar-sdk/issues/638)
    const errorMsg = e ? String(e) : ''

    if (e instanceof NotFoundError || errorMsg.match(/status code 404/)) {
      return []
    }

    if (errorMsg.match(/status code 4[0-9]{2}/)) {
      throw new LedgerAPI4xx()
    }

    if (errorMsg.match(/status code 5[0-9]{2}/)) {
      throw new LedgerAPI5xx()
    }

    if (
      e instanceof NetworkError ||
      errorMsg.match(/ECONNRESET|ECONNREFUSED|ENOTFOUND|EPIPE|ETIMEDOUT/) ||
      errorMsg.match(/undefined is not an object/)
    ) {
      throw new NetworkDown()
    }

    throw e
  }
}

// https://developers.stellar.org/docs/data/horizon/api-reference/get-operations-by-account-id
export async function fetchOperations({
  accountId,
  addr,
  minHeight,
  order,
  cursor,
  limit,
}: {
  accountId: string
  addr: string
  minHeight: number
  order: 'asc' | 'desc'
  cursor: string | undefined
  limit?: number | undefined
}): Promise<Page<Operation>> {
  const noResult: Page<Operation> = { items: [], next: '' }
  if (!addr) {
    return noResult
  }

  const requestedLimit = limit ?? coinConfig.getCoinConfig().explorer.fetchLimit ?? FETCH_LIMIT

  try {
    const rawOperations = await getServer()
      .operations()
      .forAccount(addr)
      .limit(requestedLimit)
      .order(order)
      .cursor(cursor ?? '')
      .includeFailed(true)
      .join('transactions')
      .call()

    if (!rawOperations || !rawOperations.records.length) {
      return noResult
    }

    const rawOps = rawOperations.records as RawOperation[]
    const filteredOps = await rawOperationsToOperations(rawOps, addr, accountId, minHeight)

    // Always return the cursor so the caller can continue pagination,
    // even when filteredOps is empty (e.g. page of unsupported types).
    const nextCursor = rawOps.length < requestedLimit ? '' : rawOps[rawOps.length - 1].paging_token

    return { items: filteredOps, next: nextCursor }
  } catch (e: unknown) {
    // FIXME: terrible hacks, because Stellar SDK fails to cast network failures to typed errors in react-native...
    // (https://github.com/stellar/js-stellar-sdk/issues/638)
    // update 2025-04-01: in case of NetworkError, the error.response fields are undefined. Hence we cannot rely on status code
    // the only way to check is the errror message, which may break at some point
    const errorMsg = e ? String(e) : ''

    if (e instanceof NotFoundError || errorMsg.match(/status code 404/)) {
      return noResult
    }
    if (errorMsg.match(/too many requests/i)) {
      throw new LedgerAPI4xx('status code 4xx', { status: 429, url: undefined, method: 'GET' })
    }
    if (errorMsg.match(/status code 4[0-9]{2}/)) {
      throw new LedgerAPI4xx()
    }
    if (errorMsg.match(/status code 5[0-9]{2}/)) {
      throw new LedgerAPI5xx()
    }

    if (
      e instanceof NetworkError ||
      errorMsg.match(/ECONNRESET|ECONNREFUSED|ENOTFOUND|EPIPE|ETIMEDOUT/) ||
      errorMsg.match(/undefined is not an object/)
    ) {
      throw new NetworkDown()
    }

    throw e
  }
}

export async function fetchAccountNetworkInfo(account: string): Promise<NetworkInfo> {
  try {
    const extendedAccount = await getServer().accounts().accountId(account).call()
    const baseReserve = getReservedBalance(extendedAccount)
    const { recommendedFee, networkCongestionLevel, baseFee } = await fetchBaseFee()

    return {
      family: 'stellar',
      fees: new BigNumber(recommendedFee.toString()),
      baseFee: new BigNumber(baseFee.toString()),
      baseReserve,
      networkCongestionLevel,
    }
  } catch {
    return {
      family: 'stellar',
      fees: new BigNumber(0),
      baseFee: new BigNumber(100),
      baseReserve: new BigNumber(0),
    }
  }
}

export async function fetchSequence(address: string): Promise<BigNumber> {
  const extendedAccount = await loadAccount(address)
  return extendedAccount ? new BigNumber(extendedAccount.sequence) : new BigNumber(0)
}

export async function fetchSigners(account: string): Promise<Signer[]> {
  try {
    const extendedAccount = await getServer().accounts().accountId(account).call()
    return extendedAccount.signers
  } catch {
    return []
  }
}

function interpretedError(error: unknown): unknown {
  const body = getHorizonErrorBody(error)
  if (body) {
    const cause = error instanceof Error ? error : new Error(String(error))
    return makeBroadcastFailedError(body, cause)
  }
  return error
}

export async function broadcastTransaction(signedTransaction: string): Promise<string> {
  try {
    patchHermesTypedArraysIfNeeded()
    const transaction = new StellarSdkTransaction(signedTransaction, Networks.PUBLIC)

    try {
      const res = await getServer().submitTransaction(transaction, {
        skipMemoRequiredCheck: true,
      })
      return res.hash
    } catch (error: unknown) {
      throw interpretedError(error)
    }
  } finally {
    // Restore
    unpatchHermesTypedArrays()
  }
}

export async function loadAccount(addr: string): Promise<Horizon.AccountResponse | null> {
  if (!addr || !addr.length) {
    return null
  }

  try {
    return await getServer().loadAccount(addr)
  } catch {
    return null
  }
}

export async function getLastBlock(): Promise<{
  height: number
  hash: string
  time: Date
}> {
  const ledger = await getServer().ledgers().order('desc').limit(1).call()
  return {
    height: ledger.records[0].sequence,
    hash: ledger.records[0].hash,
    time: new Date(ledger.records[0].closed_at),
  }
}

/**
 * Loads a single ledger (closed block) by sequence number.
 *
 * Horizon `GET /ledgers/{sequence}` returns one ledger resource, not a page with `records`;
 * do not read `records[0]` from this `.call()` result.
 *
 * @throws Error when the ledger does not exist (Horizon 404).
 */
export async function fetchLedgerRecord(sequence: number): Promise<Horizon.ServerApi.LedgerRecord> {
  try {
    const record = await getServer().ledgers().ledger(sequence).call()
    // Horizon returns a single ledger; SDK typings still use CollectionPage for this builder.
    return record as unknown as Horizon.ServerApi.LedgerRecord
  } catch (e: unknown) {
    throwHorizonLedgerOrOperationsError(e, `Stellar ledger ${sequence} not found`)
  }
}

/**
 * Returns all operations included in `ledgerSequence`, ascending, including failed txs,
 * with joined transaction payloads (same pattern as account operation listing).
 *
 * @throws Error when the ledger does not exist (Horizon 404).
 */
export async function fetchAllLedgerOperations(ledgerSequence: number): Promise<RawOperation[]> {
  const limit = coinConfig.getCoinConfig().explorer.fetchLimit ?? FETCH_LIMIT

  try {
    let response = await getServer()
      .operations()
      .forLedger(ledgerSequence)
      .includeFailed(true)
      .join('transactions')
      .order('asc')
      .limit(limit)
      .call()

    const records: RawOperation[] = [...(response.records as RawOperation[])]

    while (response.records.length === limit) {
      response = await response.next()
      records.push(...(response.records as RawOperation[]))
      if (response.records.length === 0) {
        break
      }
    }

    return records
  } catch (e: unknown) {
    throwHorizonLedgerOrOperationsError(e, `Stellar ledger ${ledgerSequence} not found`)
  }
}

export const getRecipientAccount: CacheRes<
  Array<{
    recipient: string
  }>,
  {
    id: string | null
    isMuxedAccount: boolean
    assetIds: string[]
  } | null
> = makeLRUCache(
  async ({ recipient }) => await recipientAccount(recipient),
  (extract) => extract.recipient,
  {
    max: 300,
    ttl: 5 * 60,
  } // 5 minutes
)

async function recipientAccount(address?: string): Promise<{
  id: string | null
  isMuxedAccount: boolean
  assetIds: string[]
} | null> {
  if (!address) {
    return null
  }

  let accountAddress = address

  const isMuxedAccount = StrKey.isValidMed25519PublicKey(address)

  if (isMuxedAccount) {
    const muxedAccount = MuxedAccount.fromAddress(address, '0')
    accountAddress = muxedAccount.baseAccount().accountId()
  }

  const account: Horizon.AccountResponse | null = await loadAccount(accountAddress)

  if (!account) {
    return null
  }

  return {
    id: account.id,
    isMuxedAccount,
    assetIds: account.balances.reduce((allAssets: any[], balance: any) => {
      return [...allAssets, getBalanceId(balance)]
    }, []),
  }
}

function getBalanceId(balance: BalanceAsset): string | null {
  switch (balance.asset_type) {
    case 'native':
      return 'native'
    case 'liquidity_pool_shares':
      return balance.liquidity_pool_id || null
    case 'credit_alphanum4':
    case 'credit_alphanum12':
      return `${balance.asset_code}:${balance.asset_issuer}`
    default:
      return null
  }
}
