import type { Abi, AbiStateMutability, Address } from 'abitype'
import { TokenId } from 'ox/tempo'
import type { Account } from '../../accounts/types.js'
import { readContract } from '../../actions/public/readContract.js'
import type { Client } from '../../clients/createClient.js'
import type { Transport } from '../../clients/transports/createTransport.js'
import type { ResolvedToken, Token } from '../../tokens/defineToken.js'
import type { Chain } from '../../types/chain.js'
import type {
  ContractFunctionName,
  ContractFunctionParameters,
  ExtractAbiItem,
} from '../../types/contract.js'
import type { Hex } from '../../types/misc.js'
import { isAddressEqual } from '../../utils/address/isAddressEqual.js'
import { encodeFunctionData } from '../../utils/index.js'
import * as Abis from '../Abis.js'

/**
 * Resolves the token contract `address` and `decimals` from a `token`, which is
 * a token symbol declared on the client's `tokens` array, a TIP20 token id, or
 * a contract `address`.
 *
 * When `token` is a declared symbol, the `address` and `decimals` are read from
 * the client's declared `tokens` (`decimals` can be overridden via the explicit
 * `decimals`). When `token` is a token id or address, its `decimals` is inferred
 * from the client's declared `tokens` when the address matches a declared token,
 * otherwise taken from the explicit `decimals`.
 *
 * When `client` is omitted, `token` must be a token id or address (symbols
 * cannot be resolved), and `decimals` is only taken from the explicit
 * `decimals`.
 *
 * @param client - Client (optional).
 * @param parameters - Parameters.
 * @returns The resolved `address` and `decimals`.
 */
export function resolveToken(
  client: Client<Transport, Chain | undefined> | undefined,
  parameters: resolveToken.Parameters,
): { address: Address; decimals: number | undefined } {
  const { decimals, token } = parameters

  if (client && typeof token === 'string') {
    const declared = findDeclaredTokenBySymbol(client, token)
    if (declared)
      return {
        address: declared.address,
        decimals: decimals ?? declared.decimals,
      }
  }

  const address = TokenId.toAddress(token as TokenId.TokenIdOrAddress)
  return {
    address,
    decimals: decimals ?? (client ? inferDecimals(client, address) : undefined),
  }
}

export namespace resolveToken {
  export type Parameters = {
    /** Decimals, if provided explicitly. */
    decimals?: number | undefined
    /** Token symbol (declared on the client's `tokens` array), TIP20 token id, or contract address. */
    token: TokenId.TokenIdOrAddress | (string & {})
  }
}

/**
 * Finds the declared {@link ResolvedToken} on the client's `tokens` array matching
 * `token`, which is either a token symbol, a TIP20 token id, or a contract
 * `address`, resolved for the client's `chain.id`. Returns `undefined` when no
 * declared token matches, or the matching token has no address for the client's
 * chain.
 *
 * @param client - Client.
 * @param token - Token symbol (declared on the client's `tokens` array), TIP20 token id, or contract address.
 * @returns The matching declared token config, or `undefined`.
 */
export function findDeclaredToken(
  client: Client<Transport, Chain | undefined>,
  token: TokenId.TokenIdOrAddress | (string & {}),
): ResolvedToken | undefined {
  const tokens = client.tokens
  const chainId = client.chain?.id
  if (!tokens || chainId === undefined) return undefined

  if (typeof token === 'string') {
    const declared = findDeclaredTokenBySymbol(client, token)
    if (declared) return declared
  }

  const address = TokenId.toAddress(token as TokenId.TokenIdOrAddress)
  for (const token_ of tokens) {
    const resolved = resolveTokenForChain(token_, chainId)
    if (resolved && isAddressEqual(resolved.address, address)) return resolved
  }
  return undefined
}

/**
 * Finds a declared token by `symbol` (case-insensitively) on the client's
 * `tokens` array, resolved for the client's `chain.id`. @internal
 */
function findDeclaredTokenBySymbol(
  client: Client<Transport, Chain | undefined>,
  symbol: string,
): ResolvedToken | undefined {
  const tokens = client.tokens
  const chainId = client.chain?.id
  if (!tokens || chainId === undefined) return undefined

  const lowerSymbol = symbol.toLowerCase()
  for (const token of tokens) {
    if (token.symbol?.toLowerCase() === lowerSymbol)
      return resolveTokenForChain(token, chainId)
  }
  return undefined
}

/**
 * Resolves a {@link Token} to a {@link ResolvedToken} for `chainId`, or
 * `undefined` when the token has no address for `chainId`. @internal
 */
function resolveTokenForChain(
  token: Token,
  chainId: number,
): ResolvedToken | undefined {
  const address = (token.addresses as Record<number, Address>)[chainId]
  if (!address) return undefined
  return {
    address,
    currency: token.currency,
    decimals: token.decimals,
    name: token.name,
    popular: token.popular,
    symbol: token.symbol,
  }
}

/**
 * Infers a token's `decimals` from the client's `tokens` array by matching
 * `address` against each token's address for the client's `chain.id`.
 * @internal
 */
function inferDecimals(
  client: Client<Transport, Chain | undefined>,
  address: Address,
): number | undefined {
  const tokens = client.tokens
  const chainId = client.chain?.id
  if (tokens && chainId !== undefined)
    for (const token of tokens) {
      const resolved = resolveTokenForChain(token, chainId)
      if (resolved && isAddressEqual(resolved.address, address))
        return resolved.decimals
    }
  return undefined
}

/**
 * Resolves token decimals, fetching from the token contract when they are not
 * provided explicitly or declared on the chain.
 * @internal
 */
export async function resolveTokenWithDecimals(
  client: Client<Transport, Chain | undefined>,
  parameters: resolveToken.Parameters,
): Promise<{ address: Address; decimals: number }> {
  const { address, decimals } = resolveToken(client, parameters)
  if (decimals !== undefined) return { address, decimals }
  return {
    address,
    decimals: await readContract(client, {
      abi: Abis.tip20,
      address,
      functionName: 'decimals',
    }),
  }
}

/**
 * Picks the transaction-override fields shared by Tempo write actions (including
 * Tempo-specific fields), so the action-specific args (`token`, `amount`, `to`,
 * etc.) don't leak into `estimateContractGas` / `simulateContract` requests.
 * @internal
 */
export function pickWriteParameters(parameters: Record<string, unknown>) {
  const {
    account,
    chain,
    feePayer,
    feeToken,
    gas,
    keyAuthorization,
    maxFeePerGas,
    maxPriorityFeePerGas,
    nonce,
    nonceKey,
    validAfter,
    validBefore,
  } = parameters
  return {
    account,
    chain,
    feePayer,
    feeToken,
    gas,
    keyAuthorization,
    maxFeePerGas,
    maxPriorityFeePerGas,
    nonce,
    nonceKey,
    validAfter,
    validBefore,
  }
}

/**
 * Parameters of an action's `.call` builder: the call arguments, optionally
 * preceded by a Client. The Client is used to resolve token symbols declared
 * on its `tokens` array and to infer token `decimals`. When the Client is
 * omitted, `token` must be a TIP20 token id or contract `address`, and
 * formatted `amount` values must carry explicit `decimals`.
 */
export type CallParameters<
  args,
  client extends Client<
    Transport,
    Chain | undefined,
    Account | undefined
  > = Client<Transport, Chain | undefined>,
  // Note: mutable (non-`readonly`) tuples, so bound decorator helpers can
  // pattern-match these parameters (see `BoundHelper` in `../Decorator.ts`).
> = [client: client, args: args] | [args: args]

/**
 * Splits {@link CallParameters} into `[client, args]`, with an `undefined`
 * client when the caller omitted it.
 * @internal
 */
export function resolveCallParameters<
  args,
  client extends Client<Transport, Chain | undefined, Account | undefined>,
>(
  parameters: CallParameters<args, client>,
): readonly [client: client | undefined, args: args] {
  if (parameters.length === 2) return parameters
  return [undefined, parameters[0]]
}

export function defineCall<
  const abi extends Abi,
  const functionName extends ContractFunctionName<abi, AbiStateMutability>,
  call extends ContractFunctionParameters<
    abi,
    AbiStateMutability,
    functionName
  >,
>(
  call:
    | call
    | ContractFunctionParameters<abi, AbiStateMutability, functionName>,
): ContractFunctionParameters<
  [ExtractAbiItem<abi, functionName>],
  AbiStateMutability,
  functionName
> & {
  data: Hex
  to: Address
} {
  return {
    ...(call as any),
    data: encodeFunctionData(call as never),
    to: call.address,
  } as const
}

/**
 * Normalizes a value into a structured-clone compatible format.
 *
 * @see https://developer.mozilla.org/en-US/docs/Web/API/Window/structuredClone
 * @internal
 */
export function normalizeValue<type>(value: type): type {
  if (Array.isArray(value)) return value.map(normalizeValue) as never
  if (typeof value === 'function') return undefined as never
  if (typeof value !== 'object' || value === null) return value
  if (Object.getPrototypeOf(value) !== Object.prototype)
    try {
      return structuredClone(value)
    } catch {
      return undefined as never
    }

  const normalized: Record<string, unknown> = {}
  for (const [k, v] of Object.entries(value)) normalized[k] = normalizeValue(v)
  return normalized as never
}
