import { Hex } from 'ox'
import {
  type Address,
  type Chain,
  type Client,
  createClient,
  formatUnits,
  http,
  parseUnits,
  type Transport,
} from 'viem'
import { tempo, tempoDevnet, tempoModerato } from 'viem/chains'
import { Actions, Addresses } from 'viem/tempo'
import * as z from 'zod/mini'

import * as ExecutionError from '../../../core/ExecutionError.js'
import * as u from '../../../core/zod/utils.js'
import { type Handler, from } from '../../Handler.js'
import * as Kv from '../../Kv.js'
import * as Hono from '../hono.js'
import { cached } from '../kv.js'
import * as Tokenlist from '../tokenlist.js'

/** Default cache TTL in seconds (10 minutes). */
const defaultCacheTtl = 10 * 60

/** Zod schemas for the exchange handler's request and response payloads. */
export namespace schema {
  const token = z.object({
    address: u.address(),
    decimals: z.number(),
    logoUri: z.optional(z.string()),
    name: z.string(),
    symbol: z.string(),
  })

  /** Schemas for `POST /exchange/quote`. */
  export namespace quote {
    /** Request body schema. */
    export const parameters = z.object({
      amount: z.string(),
      chainId: z.optional(z.number()),
      pairToken: z.string(),
      slippage: z.number(),
      token: z.string(),
      type: z.union([z.literal('buy'), z.literal('sell')]),
    })

    const side = z.object({
      address: u.address(),
      amount: z.string(),
      name: z.string(),
      symbol: z.string(),
    })

    /** Response body schema. */
    export const returns = z.object({
      calls: z.readonly(
        z.array(
          z.object({
            data: u.hex(),
            to: u.address(),
          }),
        ),
      ),
      pairToken: side,
      slippage: z.number(),
      token: side,
      type: z.union([z.literal('buy'), z.literal('sell')]),
    })
  }

  /** Schemas for `GET /exchange/tokens`. */
  export namespace tokens {
    /** Query string schema. `chainId` is a decimal string when present. */
    export const parameters = z.object({
      chainId: z.optional(z.string()),
    })

    /** Response body schema. */
    export const returns = z.object({
      tokens: z.readonly(z.array(token)),
    })
  }
}

/**
 * Instantiates a stablecoin-exchange handler that returns swap quotes plus
 * the matching `calls` (approve + buy/sell) for the Tempo Stablecoin DEX.
 *
 * Exposes 2 endpoints:
 * - `GET /exchange/tokens` — list known tokens for a chain (defaults to the
 *   first configured chain; pass `?chainId=N` to pick a different one).
 * - `POST /exchange/quote` — return a quote and ready-to-submit calls.
 *
 * The returned value is a Hono app augmented with a Node `listener`. The full
 * route schema is preserved on the type so consumers can derive a typed RPC
 * client with `hc<ReturnType<typeof exchange>>(url)`.
 *
 * @example
 * ```ts
 * import { Handler } from 'accounts/server'
 *
 * const handler = Handler.exchange()
 *
 * // Plug handler into your server framework of choice:
 * createServer(handler.listener)
 * ```
 *
 * @example
 * Typed RPC client
 *
 * ```ts
 * import { hc } from 'hono/client'
 * import { Handler } from 'accounts/server'
 *
 * const handler = Handler.exchange()
 * type Handler = typeof handler
 *
 * const client = hc<Handler>('https://example.com')
 * const res = await client.exchange.quote.$post({
 *   json: {
 *     amount: '1',
 *     pairToken: 'pathUSD',
 *     slippage: 0.01,
 *     token: 'USDC',
 *     type: 'sell',
 *   },
 * })
 * if (res.ok) {
 *   const { calls, pairToken, token } = await res.json()
 *   // fully typed
 * }
 * ```
 *
 * @param options - Options.
 * @returns Request handler.
 */
export function exchange<const path extends string = '/exchange'>(
  options: exchange.Options<path> = {},
) {
  const {
    cacheTtl = defaultCacheTtl,
    chains = [tempo, tempoModerato, tempoDevnet],
    kv = Kv.memory(),
    onRequest,
    path = '/exchange' as path,
    resolveTokens,
    transports = {},
    ...rest
  } = options

  const getTokens = (chainId: number) =>
    Tokenlist.fetch(chainId, kv, { cacheTtl, resolver: resolveTokens })

  const clients = new Map<number, Client>()
  for (const chain of chains) {
    const transport = transports[chain.id] ?? http()
    clients.set(
      chain.id,
      createClient({ batch: { multicall: { deployless: true } }, chain, transport }),
    )
  }

  const router = from(rest)

  const app = router
    .get(`${path}/tokens`, Hono.validate('query', schema.tokens.parameters), async (c) => {
      try {
        await onRequest?.(c.req.raw)
        const { chainId: chainIdStr } = c.req.valid('query')

        const chainId = chainIdStr ? Number(chainIdStr) : chains[0]!.id
        const chain = chains.find((c) => c.id === chainId)
        if (!chain) throw new Error(`Chain ${chainId} is not supported.`)

        const tokens = await getTokens(chain.id)

        // Cache for `cacheTtl` and allow stale-while-revalidate for an
        // additional full TTL window so CDNs/browsers can serve immediately
        // while a fresh copy is fetched in the background.
        c.header(
          'Cache-Control',
          `public, max-age=${cacheTtl}, s-maxage=${cacheTtl}, stale-while-revalidate=${cacheTtl}`,
        )

        return c.json(
          z.encode(schema.tokens.returns, { tokens }) as z.output<typeof schema.tokens.returns>,
        )
      } catch (error) {
        return c.json({ error: (error as Error).message }, 400)
      }
    })
    .post(`${path}/quote`, Hono.validate('json', schema.quote.parameters), async (c) => {
      try {
        await onRequest?.(c.req.raw)
        const { amount, chainId, pairToken, slippage, token, type } = c.req.valid('json')

        const chain = chainId ? chains.find((c) => c.id === chainId) : chains[0]
        if (!chain) throw new Error(`Chain ${chainId} is not supported.`)
        const client = clients.get(chain.id)!

        // Resolve `token` and `pairToken` to addresses + metadata in parallel.
        const tokens = await getTokens(chain.id)
        const [tokenInfo, pairTokenInfo] = await Promise.all([
          resolveToken(client, { kv, ref: token, tokens }),
          resolveToken(client, { kv, ref: pairToken, tokens }),
        ])

        // `amount` is always denominated in `token` units. For `buy`, that
        // means the exact `token` to receive (exact-out); for `sell`, the
        // exact `token` to spend (exact-in).
        const amount_ = parseUnits(amount, tokenInfo.decimals)

        // `buy` spends `pairToken` to receive `token`; `sell` spends `token`
        // to receive `pairToken`.
        const inInfo = type === 'buy' ? pairTokenInfo : tokenInfo
        const outInfo = type === 'buy' ? tokenInfo : pairTokenInfo

        const result =
          type === 'buy'
            ? await quoteBuy(client, {
                amount: amount_,
                input: inInfo.address,
                output: outInfo.address,
                slippage,
              })
            : await quoteSell(client, {
                amount: amount_,
                input: inInfo.address,
                output: outInfo.address,
                slippage,
              })

        // Map the trade-direction-relative amounts back onto the
        // `token`/`pairToken` axis the caller speaks in.
        const tokenAmount = type === 'buy' ? result.outputAmount : result.inputAmount
        const pairTokenAmount = type === 'buy' ? result.inputAmount : result.outputAmount

        return c.json(
          z.encode(schema.quote.returns, {
            calls: result.calls,
            pairToken: {
              address: pairTokenInfo.address,
              amount: formatUnits(pairTokenAmount, pairTokenInfo.decimals),
              name: pairTokenInfo.name,
              symbol: pairTokenInfo.symbol,
            },
            slippage,
            token: {
              address: tokenInfo.address,
              amount: formatUnits(tokenAmount, tokenInfo.decimals),
              name: tokenInfo.name,
              symbol: tokenInfo.symbol,
            },
            type,
          }) as z.output<typeof schema.quote.returns>,
        )
      } catch (error) {
        const revert = ExecutionError.parse(error as Error)
        // Only surface decoded reverts (named ABI errors). Anything else
        // (network errors, unknown symbols) falls through to the plain
        // message path.
        if (revert && revert.errorName !== 'unknown')
          return c.json({ data: ExecutionError.serialize(revert), error: revert.errorName }, 400)
        return c.json({ error: (error as Error).message }, 400)
      }
    })

  return app as Handler<typeof app>
}

export declare namespace exchange {
  /** Options for `exchange()`. */
  export type Options<path extends string = string> = from.Options & {
    /**
     * TTL in seconds for cached tokenlist responses. On-chain token metadata
     * is cached without expiry (immutable per address).
     * @default 600 (10 minutes)
     */
    cacheTtl?: number | undefined
    /**
     * Supported chains. The first chain is used to resolve the client.
     * @default [tempo, tempoModerato, tempoDevnet]
     */
    chains?: readonly [Chain, ...Chain[]] | undefined
    /**
     * Kv store used to cache network responses. Provide `Kv.cloudflare(env.KV)`
     * for cross-instance caching, or omit for an in-process LRU.
     * @default Kv.memory()
     */
    kv?: Kv.Kv | undefined
    /** Function to call before handling the request. */
    onRequest?: ((request: Request) => void | Promise<void>) | undefined
    /** Path prefix for the exchange endpoints. @default '/exchange' */
    path?: path | undefined
    /**
     * Resolves the list of known tokens for a chain. Used to resolve symbol
     * references (e.g. `"USDC.e"`) to addresses + metadata. Address references
     * are matched against this list first, falling back to on-chain metadata.
     * @default Fetches `https://tokenlist.tempo.xyz/list/:chainId`
     */
    resolveTokens?: ((chainId: number) => readonly Token[] | Promise<readonly Token[]>) | undefined
    /** Transports keyed by chain ID. Defaults to `http()` per chain. */
    transports?: Record<number, Transport> | undefined
  }

  /** Resolved token metadata. */
  export type Token = {
    /** Token address. */
    address: Address
    /** Token decimals. */
    decimals: number
    /** Token logo URI. */
    logoUri?: string | undefined
    /** Token name. */
    name: string
    /** Token symbol. */
    symbol: string
  }
}

type Token = exchange.Token

/**
 * Resolves a token reference to an address + metadata.
 *
 * If `ref` looks like a hex address (`0x...`), it is matched against `tokens`
 * by address; on miss, metadata is fetched on-chain and cached forever.
 * Otherwise it's treated as a symbol and matched against `tokens` by symbol.
 */
async function resolveToken(client: Client, options: resolveToken.Parameters): Promise<Token> {
  const { kv, ref, tokens } = options
  const chainId = client.chain!.id

  if (isAddress(ref)) {
    const refLower = ref.toLowerCase()
    const found = tokens.find((t) => t.address.toLowerCase() === refLower)
    if (found) return found

    return await cached(kv, `metadata:${chainId}:${refLower}`, async () => {
      const meta = await Actions.token.getMetadata(client, { token: ref }).catch(() => undefined)
      return {
        address: ref,
        decimals: meta?.decimals ?? 6,
        name: meta?.name ?? '',
        symbol: meta?.symbol ?? '',
      }
    })
  }

  const found = tokens.find((t) => t.symbol === ref)
  if (!found) throw new Error(`Token "${ref}" not found`)
  return found
}

declare namespace resolveToken {
  /** Parameters for `resolveToken()`. */
  type Parameters = {
    /** Kv used to cache on-chain metadata fetches. */
    kv: Kv.Kv
    /** Token reference: a hex address (`0x...`) or symbol. */
    ref: string
    /** Known tokens for the chain (used for fast symbol/address lookup). */
    tokens: readonly Token[]
  }
}

function isAddress(value: string): value is Address {
  return /^0x[0-9a-fA-F]{40}$/.test(value)
}

/** Result of a quote helper, before encoding for the response. */
type QuoteResult = {
  calls: readonly { data: Hex.Hex; to: Address }[]
  inputAmount: bigint
  outputAmount: bigint
}

async function quoteBuy(client: Client, options: quoteBuy.Parameters): Promise<QuoteResult> {
  const { amount, input, output, slippage } = options
  // exact-out: amount = exact `output` to receive.
  const quoteIn = await Actions.dex.getBuyQuote(client, {
    amountOut: amount,
    tokenIn: input,
    tokenOut: output,
  })
  const maxAmountIn = applySlippage(quoteIn, slippage, 'up')

  const approve = Actions.token.approve.call({
    amount: maxAmountIn,
    spender: Addresses.stablecoinDex,
    token: input,
  })
  const buy = Actions.dex.buy.call({
    amountOut: amount,
    maxAmountIn,
    tokenIn: input,
    tokenOut: output,
  })

  return {
    calls: [toCall(approve), toCall(buy)],
    inputAmount: maxAmountIn,
    outputAmount: amount,
  }
}

declare namespace quoteBuy {
  /** Parameters for `quoteBuy()`. */
  type Parameters = {
    /** Exact `output` amount to receive. */
    amount: bigint
    /** Token spent. */
    input: Address
    /** Token received. */
    output: Address
    /** Slippage tolerance (e.g. `0.05` = 5%). */
    slippage: number
  }
}

async function quoteSell(client: Client, options: quoteSell.Parameters): Promise<QuoteResult> {
  const { amount, input, output, slippage } = options
  // exact-in: amount = exact `input` to spend.
  const quoteOut = await Actions.dex.getSellQuote(client, {
    amountIn: amount,
    tokenIn: input,
    tokenOut: output,
  })
  const minAmountOut = applySlippage(quoteOut, slippage, 'down')

  const approve = Actions.token.approve.call({
    amount,
    spender: Addresses.stablecoinDex,
    token: input,
  })
  const sell = Actions.dex.sell.call({
    amountIn: amount,
    minAmountOut,
    tokenIn: input,
    tokenOut: output,
  })

  return {
    calls: [toCall(approve), toCall(sell)],
    inputAmount: amount,
    outputAmount: minAmountOut,
  }
}

declare namespace quoteSell {
  /** Parameters for `quoteSell()`. */
  type Parameters = {
    /** Exact `input` amount to spend. */
    amount: bigint
    /** Token spent. */
    input: Address
    /** Token received. */
    output: Address
    /** Slippage tolerance (e.g. `0.05` = 5%). */
    slippage: number
  }
}

function applySlippage(amount: bigint, slippage: number, dir: 'up' | 'down') {
  const bps = BigInt(Math.round(slippage * 10_000))
  if (dir === 'up') return amount + (amount * bps) / 10_000n
  return amount - (amount * bps) / 10_000n
}

function toCall(call: { data: Hex.Hex; to: Address }) {
  return { data: call.data, to: call.to }
}
