// ---------------------------------------------------------------------------
// Blockbook WebSocket client (Trezor's Bitcoin indexer)
// ---------------------------------------------------------------------------
//
// Trezor Blockbook exposes both a REST API and a WebSocket API at the same
// host. We use the WebSocket API exclusively because:
//
//   1. CORS — `btc.trezor.io` (and the other public mirrors) do not send
//      `Access-Control-Allow-Origin`, so browsers reject every REST response.
//      WebSocket upgrades are not preflighted and have no same-origin
//      requirement on the response side, so they Just Work from any origin.
//   2. Efficiency — a single persistent connection multiplexes every request
//      we make for a wallet session (snapshot, utxos, fee, broadcast) instead
//      of paying TCP+TLS setup costs per request.
//   3. Parity — Trezor Suite itself uses WebSocket; it is the production
//      transport. The REST API is a thin compatibility layer.
//
// We support exactly one Blockbook base URL (no failover list). If the
// server is down or unreachable, errors are surfaced to the user; there is
// no Esplora fallback for the HD wallet.
//
// **Privacy**: every request to this client carries the full account xpub
// (wrapped as `tr(xpub)` descriptor). Whoever operates the configured
// endpoint can link every wallet address and observe balance/spending
// over time. This is the trade-off for the single-call architecture.
//
// ---------------------------------------------------------------------------
// Wire protocol (Blockbook WebSocket)
// ---------------------------------------------------------------------------
//
// Endpoint:   wss://<host>/websocket
//
// Request:    { "id": "<string>", "method": "<name>", "params": { ... } }
// Response:   { "id": "<echoed>", "data": <payload-or-error> }
//
// On error, `data` is shaped `{ "error": { "message": "<reason>" } }`.
//
// We map: HTTP base URL → WS URL by replacing the scheme (`http`→`ws`,
// `https`→`wss`) and appending `/websocket` if not already present.
// ---------------------------------------------------------------------------

// ---------------------------------------------------------------------------
// URL transform
// ---------------------------------------------------------------------------

/**
 * Convert a Blockbook HTTP(S) base URL into the matching WebSocket URL.
 *
 *     https://btc.trezor.io         → wss://btc.trezor.io/websocket
 *     https://btc.trezor.io/        → wss://btc.trezor.io/websocket
 *     wss://example.com/websocket   → wss://example.com/websocket   (unchanged)
 */
function toWebsocketUrl(baseUrl: string): string {
  let url = baseUrl.trim();
  if (url.startsWith('https://')) {
    url = 'wss://' + url.slice('https://'.length);
  } else if (url.startsWith('http://')) {
    url = 'ws://' + url.slice('http://'.length);
  }
  if (url.endsWith('/')) url = url.slice(0, -1);
  if (!url.endsWith('/websocket')) url += '/websocket';
  return url;
}

// ---------------------------------------------------------------------------
// Socket pool — one persistent connection per configured base URL
// ---------------------------------------------------------------------------

const DEFAULT_TIMEOUT_MS = 30_000;
const IDLE_DISCONNECT_MS = 90_000;
const CONNECT_TIMEOUT_MS = 15_000;

interface PendingRequest {
  resolve: (data: unknown) => void;
  reject: (err: Error) => void;
  timer: ReturnType<typeof setTimeout>;
  abortHandler?: () => void;
  signal?: AbortSignal;
}

class BlockbookSocket {
  private ws?: WebSocket;
  private connectPromise?: Promise<WebSocket>;
  private nextId = 1;
  private readonly pending = new Map<string, PendingRequest>();
  private idleTimer?: ReturnType<typeof setTimeout>;

  constructor(private readonly url: string) {}

  private resetIdleTimer() {
    if (this.idleTimer) clearTimeout(this.idleTimer);
    this.idleTimer = setTimeout(() => {
      if (this.pending.size === 0) this.disconnect('idle');
    }, IDLE_DISCONNECT_MS);
  }

  private disconnect(reason: string) {
    if (this.idleTimer) {
      clearTimeout(this.idleTimer);
      this.idleTimer = undefined;
    }
    const ws = this.ws;
    this.ws = undefined;
    this.connectPromise = undefined;
    if (ws && ws.readyState !== WebSocket.CLOSED) {
      try { ws.close(1000, reason); } catch { /* ignore */ }
    }
  }

  /** Fail every in-flight request with the given error. */
  private failAll(err: Error) {
    for (const [, p] of this.pending) {
      clearTimeout(p.timer);
      if (p.signal && p.abortHandler) p.signal.removeEventListener('abort', p.abortHandler);
      p.reject(err);
    }
    this.pending.clear();
  }

  private async connect(): Promise<WebSocket> {
    if (this.ws && this.ws.readyState === WebSocket.OPEN) return this.ws;
    if (this.connectPromise) return this.connectPromise;

    this.connectPromise = new Promise<WebSocket>((resolve, reject) => {
      let ws: WebSocket;
      try {
        ws = new WebSocket(this.url);
      } catch (err) {
        reject(err instanceof Error ? err : new Error(String(err)));
        return;
      }

      const connectTimer = setTimeout(() => {
        try { ws.close(); } catch { /* ignore */ }
        reject(new Error('Blockbook WebSocket connect timed out'));
      }, CONNECT_TIMEOUT_MS);

      ws.addEventListener('open', () => {
        clearTimeout(connectTimer);
        this.ws = ws;
        this.resetIdleTimer();
        resolve(ws);
      }, { once: true });

      ws.addEventListener('error', () => {
        clearTimeout(connectTimer);
        const err = new Error(`Blockbook WebSocket error (${this.url})`);
        this.failAll(err);
        this.disconnect('error');
        reject(err);
      }, { once: true });

      ws.addEventListener('close', (ev) => {
        clearTimeout(connectTimer);
        const err = new Error(
          `Blockbook WebSocket closed${ev.code ? ` (${ev.code})` : ''}${ev.reason ? `: ${ev.reason}` : ''}`,
        );
        this.failAll(err);
        if (this.ws === ws) this.disconnect('close');
      });

      ws.addEventListener('message', (ev) => {
        this.onMessage(ev.data);
      });
    }).finally(() => {
      this.connectPromise = undefined;
    });

    return this.connectPromise;
  }

  private onMessage(raw: unknown) {
    if (typeof raw !== 'string') return;
    let parsed: { id?: string; data?: unknown };
    try {
      parsed = JSON.parse(raw) as { id?: string; data?: unknown };
    } catch {
      return;
    }
    if (!parsed.id) return;
    const pending = this.pending.get(parsed.id);
    if (!pending) return; // late or duplicate response
    this.pending.delete(parsed.id);
    clearTimeout(pending.timer);
    if (pending.signal && pending.abortHandler) {
      pending.signal.removeEventListener('abort', pending.abortHandler);
    }
    this.resetIdleTimer();

    const data = parsed.data as { error?: { message?: string } } | undefined;
    if (data && typeof data === 'object' && 'error' in data && data.error) {
      const msg = typeof data.error.message === 'string' ? data.error.message : 'Blockbook error';
      pending.reject(new Error(msg));
      return;
    }
    pending.resolve(parsed.data);
  }

  /** Send a JSON-RPC-style message and await the matching response. */
  async send<T>(method: string, params: Record<string, unknown> = {}, signal?: AbortSignal): Promise<T> {
    if (signal?.aborted) throw new Error('Request aborted');

    const ws = await this.connect();
    const id = String(this.nextId++);
    const req = JSON.stringify({ id, method, params });

    return new Promise<T>((resolve, reject) => {
      const timer = setTimeout(() => {
        if (this.pending.delete(id)) {
          if (signal && abortHandler) signal.removeEventListener('abort', abortHandler);
          reject(new Error(`Blockbook ${method} timed out`));
        }
      }, DEFAULT_TIMEOUT_MS);

      const abortHandler = signal
        ? () => {
            if (this.pending.delete(id)) {
              clearTimeout(timer);
              signal.removeEventListener('abort', abortHandler!);
              reject(new Error('Request aborted'));
            }
          }
        : undefined;
      if (signal && abortHandler) signal.addEventListener('abort', abortHandler, { once: true });

      this.pending.set(id, {
        resolve: (data) => resolve(data as T),
        reject,
        timer,
        abortHandler,
        signal,
      });

      try {
        ws.send(req);
      } catch (err) {
        this.pending.delete(id);
        clearTimeout(timer);
        if (signal && abortHandler) signal.removeEventListener('abort', abortHandler);
        reject(err instanceof Error ? err : new Error(String(err)));
      }
    });
  }
}

const sockets = new Map<string, BlockbookSocket>();

function getSocket(baseUrl: string): BlockbookSocket {
  const wsUrl = toWebsocketUrl(baseUrl);
  let s = sockets.get(wsUrl);
  if (!s) {
    s = new BlockbookSocket(wsUrl);
    sockets.set(wsUrl, s);
  }
  return s;
}

// ---------------------------------------------------------------------------
// getAccountInfo — full xpub snapshot
// ---------------------------------------------------------------------------

/** A used-address record returned inside the xpub response under `tokens`. */
export interface BlockbookXpubAddress {
  /** Token type ("XPUBAddress" for Bitcoin xpub-derived addresses). */
  type: string;
  /** The derived Bitcoin address. */
  name: string;
  /** Full BIP32 path, e.g. `m/86'/0'/0'/0/3`. */
  path: string;
  /** Number of transfers (txs) touching this address. */
  transfers: number;
  /** Decimals (8 for Bitcoin). */
  decimals: number;
  /** Confirmed balance as a string of satoshis ("0", "12345", …). */
  balance: string;
  /** Total ever received, sats. */
  totalReceived: string;
  /** Total ever sent, sats. */
  totalSent: string;
}

/**
 * Esplora-style tx row inside the Blockbook xpub `txs` response.
 *
 * Blockbook returns Bitcoin txs with this shape (`Tx` type in the API doc).
 * The fields we actually consume are the txid, status (via `confirmations`),
 * block height/time, and the vin/vout addresses + values used to compute
 * the net effect on the wallet.
 */
export interface BlockbookTx {
  txid: string;
  blockHeight?: number;
  /** -1 for mempool txs. */
  confirmations: number;
  /** Block time (confirmed) or first-seen time (mempool). Unix seconds. */
  blockTime?: number;
  vin: Array<{
    addresses?: string[];
    isAddress?: boolean;
    /** Sats spent by this input, as a string. */
    value?: string;
    txid?: string;
    vout?: number;
  }>;
  vout: Array<{
    addresses?: string[];
    isAddress?: boolean;
    /** Sats sent by this output, as a string. */
    value: string;
    n: number;
  }>;
  /** Net tx value (sats, string). */
  value?: string;
  /** Total input value (sats, string). */
  valueIn?: string;
  /** Fee paid (sats, string). */
  fees?: string;
}

/** Response from the `getAccountInfo` WS method (mirrors REST `/api/v2/xpub`). */
export interface BlockbookXpubResponse {
  page?: number;
  totalPages?: number;
  itemsOnPage?: number;
  /** Echo of the descriptor we sent. */
  address: string;
  /** Confirmed balance (sats, string). */
  balance: string;
  totalReceived: string;
  totalSent: string;
  /** Mempool delta (sats, string). May be negative. */
  unconfirmedBalance: string;
  unconfirmedTxs: number;
  /** Total confirmed tx count across all derived addresses. */
  txs: number;
  /** Count of used derived addresses ("tokens" in Blockbook parlance). */
  usedTokens?: number;
  /** Used derived addresses with per-address stats. Only present when
   *  `details >= tokenBalances`. */
  tokens?: BlockbookXpubAddress[];
  /** Recent transactions (newest first). Present when `details=txs`. */
  transactions?: BlockbookTx[];
}

/**
 * Fetch the full xpub snapshot from Blockbook in a single WebSocket call.
 *
 * @param baseUrl     Blockbook base URL (HTTP or WSS). The function converts
 *                    `https://host` → `wss://host/websocket` internally.
 * @param descriptor  Output descriptor, e.g. `tr(xpub6...)`.
 * @param signal      Optional abort signal.
 *
 * Params sent:
 *   - `details=txs`     — include the full tx list (default would be `txids`).
 *   - `tokens=used`     — restrict the `tokens` array to addresses with
 *                         at least one tx (Blockbook does the gap-limit walk
 *                         for us).
 *   - `pageSize=1000`   — max page size; covers any practical HD wallet.
 */
export async function fetchXpubSnapshot(
  baseUrl: string,
  descriptor: string,
  signal?: AbortSignal,
): Promise<BlockbookXpubResponse> {
  return getSocket(baseUrl).send<BlockbookXpubResponse>(
    'getAccountInfo',
    {
      descriptor,
      details: 'txs',
      tokens: 'used',
      pageSize: 1000,
    },
    signal,
  );
}

// ---------------------------------------------------------------------------
// getAccountUtxo
// ---------------------------------------------------------------------------

/** A UTXO row from Blockbook (xpub endpoint includes `address` + `path`). */
export interface BlockbookUtxo {
  txid: string;
  vout: number;
  /** Value as a string of sats. */
  value: string;
  /** Block height; absent for mempool UTXOs. */
  height?: number;
  /** 0 for mempool, >0 for confirmed. */
  confirmations: number;
  /** Locktime, only set for unconfirmed UTXOs with non-zero locktime. */
  lockTime?: number;
  /** True for coinbase UTXOs within the maturity window. */
  coinbase?: boolean;
  /** Address holding the UTXO (xpub endpoint only). */
  address?: string;
  /** BIP32 path under the xpub, e.g. `m/86'/0'/0'/0/3` (xpub endpoint only). */
  path?: string;
}

/**
 * Fetch all UTXOs spendable by the descriptor.
 *
 * Blockbook returns confirmed + unconfirmed by default. Each entry carries
 * its derivation path, so we can recover the (chain, index) pair needed to
 * sign without re-deriving from the address string.
 */
export async function fetchXpubUtxos(
  baseUrl: string,
  descriptor: string,
  signal?: AbortSignal,
): Promise<BlockbookUtxo[]> {
  return getSocket(baseUrl).send<BlockbookUtxo[]>('getAccountUtxo', { descriptor }, signal);
}

// ---------------------------------------------------------------------------
// estimateFee — fee rates for the four UI buckets in a single call
// ---------------------------------------------------------------------------

/** Fee rate estimates for the four UI-exposed speed buckets. */
export interface BlockbookFeeRates {
  /** ~10 min / next block. */
  fastestFee: number;
  /** ~30 min (3 blocks). */
  halfHourFee: number;
  /** ~1 hour (6 blocks). */
  hourFee: number;
  /** ~1 day (144 blocks). */
  economyFee: number;
}

/**
 * WS `estimateFee` response: one entry per requested block target, in
 * request order. `feePerUnit` is sat/**kB** for Bitcoin-type chains —
 * the same unit as Bitcoin Core's `estimatesmartfee` after BTC→sat
 * conversion. The TypeScript declaration in `blockbook-api.ts`
 * describes it as "sat/byte", but Blockbook's Go source is explicit
 * (`// fee is in sats/kB` in `api/worker.go`) and confirmed against a
 * live mainnet endpoint where the field is ~3000 at typical mempool
 * conditions. Dividing by 1000 yields the sat/vB value we use
 * everywhere else in the wallet.
 */
interface BlockbookFeeEntry {
  feePerUnit?: string;
  /** Some chains return additional fields we don't use. */
  [key: string]: unknown;
}

function parseFeePerUnit(entry: BlockbookFeeEntry | undefined): number {
  if (!entry || typeof entry.feePerUnit !== 'string') return 1;
  const satsPerKb = parseFloat(entry.feePerUnit);
  if (!Number.isFinite(satsPerKb) || satsPerKb <= 0) return 1;
  // sat/kB → sat/vB. Round up so we never underpay relative to the
  // backend's recommendation (which can cause stuck transactions).
  return Math.max(1, Math.ceil(satsPerKb / 1000));
}

/**
 * Fetch all four fee tiers in one WebSocket call.
 *
 * The WS API accepts a `blocks` array and returns an array of fee entries
 * in the same order — far more efficient than four REST round-trips.
 */
export async function fetchFeeRates(
  baseUrl: string,
  signal?: AbortSignal,
): Promise<BlockbookFeeRates> {
  const blocks = [1, 3, 6, 144];
  const result = await getSocket(baseUrl).send<BlockbookFeeEntry[]>(
    'estimateFee',
    { blocks },
    signal,
  );
  const arr = Array.isArray(result) ? result : [];
  return {
    fastestFee: parseFeePerUnit(arr[0]),
    halfHourFee: parseFeePerUnit(arr[1]),
    hourFee: parseFeePerUnit(arr[2]),
    economyFee: parseFeePerUnit(arr[3]),
  };
}

// ---------------------------------------------------------------------------
// sendTransaction — broadcast
// ---------------------------------------------------------------------------

interface BlockbookSendResponse {
  result: string;
}

/**
 * Broadcast a signed transaction. Returns the txid on success.
 */
export async function broadcastBlockbookTx(
  baseUrl: string,
  txHex: string,
  signal?: AbortSignal,
): Promise<string> {
  const data = await getSocket(baseUrl).send<BlockbookSendResponse>(
    'sendTransaction',
    { hex: txHex },
    signal,
  );
  if (!data || typeof data.result !== 'string') {
    throw new Error('Broadcast failed: malformed response');
  }
  return data.result;
}

// ---------------------------------------------------------------------------
// getAccountInfo (single address) — transaction-history probe
// ---------------------------------------------------------------------------
//
// Used by the private-wallet send flow to warn before sending a silent-
// payment spend to a reused on-chain address. Blockbook's `getAccountInfo`
// accepts a bare address as the `descriptor`; with `details: 'basic'` it
// returns aggregate counters (`txs`, `totalReceived`, `balance`) without the
// full tx list — enough to decide whether the address has ever been used.

/** Subset of the Blockbook `getAccountInfo` response we consume for an address. */
interface BlockbookAccountInfo {
  /** Total number of transactions touching the address. */
  txs?: number;
  /** Total ever received, sats (string). */
  totalReceived?: string;
  /** Current confirmed balance, sats (string). */
  balance?: string;
}

/** Result of an address-history probe. */
export interface AddressHistoryInfo {
  /** Whether the address has ANY on-chain history (received funds or txs). */
  hasHistory: boolean;
  /** Number of transactions touching the address (0 when unused). */
  txCount: number;
}

/**
 * Probe whether an on-chain Bitcoin address has any transaction history.
 *
 * A `true` result means the address has been used before — sending to it
 * links the new payment to that history, which is precisely what the private
 * (silent-payment) wallet exists to avoid. The caller surfaces a warning and
 * requires explicit confirmation before proceeding.
 *
 * Network failures are surfaced (thrown) so the UI can decide how to treat an
 * inconclusive probe; callers that prefer to fail open should catch and treat
 * the result as "unknown".
 */
export async function fetchAddressInfo(
  baseUrl: string,
  address: string,
  signal?: AbortSignal,
): Promise<AddressHistoryInfo> {
  const data = await getSocket(baseUrl).send<BlockbookAccountInfo>(
    'getAccountInfo',
    { descriptor: address, details: 'basic' },
    signal,
  );
  const txCount = typeof data?.txs === 'number' ? data.txs : 0;
  const received = data?.totalReceived ? Number(data.totalReceived) : 0;
  const balance = data?.balance ? Number(data.balance) : 0;
  const hasHistory = txCount > 0 || received > 0 || balance > 0;
  return { hasHistory, txCount };
}

// ---------------------------------------------------------------------------
// getTransaction — per-vout spent status
// ---------------------------------------------------------------------------
//
// Used by the silent-payments orchestrator to reconcile the persisted SP
// UTXO set against on-chain reality: a stored UTXO whose `(txid, vout)` is
// reported `spent: true` by Blockbook is pruned from the encrypted NIP-78
// doc. This is the only mechanism that learns about SP UTXOs spent from a
// *different* device or by a pre-fix build — Blockbook's xpub scan can't
// observe SP outputs (they aren't under the BIP-86 hierarchy), so the
// chain-scan side has no way to mark them gone.

/** A vout entry in the Blockbook `getTransaction` response. */
interface BlockbookTxVout {
  /** Output index (0-based). */
  n: number;
  /** Value in sats, as a string. */
  value?: string;
  /** True if this output has been spent in a later transaction. */
  spent?: boolean;
}

/** Subset of the `getTransaction` response we consume. */
interface BlockbookTransaction {
  txid: string;
  vout: BlockbookTxVout[];
}

/**
 * Fetch a transaction by txid. Returns the full Blockbook response
 * including per-vout `spent` flags — the field the wallet uses to learn
 * whether a stored SP UTXO is still unspent.
 */
async function fetchBlockbookTransaction(
  baseUrl: string,
  txid: string,
  signal?: AbortSignal,
): Promise<BlockbookTransaction> {
  return getSocket(baseUrl).send<BlockbookTransaction>(
    'getTransaction',
    { txid },
    signal,
  );
}

/**
 * For each `(txid, vout)` in `utxos`, ask Blockbook whether that output is
 * spent. Returns a map keyed `"txid:vout"` → boolean.
 *
 * Fires one WS call per distinct txid (typically equal to `utxos.length`
 * since most txids contribute a single SP output to us). Individual lookup
 * failures are logged and skipped — the caller should treat absence from
 * the map as "unknown, leave the UTXO in place" rather than "assumed
 * unspent" or "assumed spent".
 */
export async function fetchUtxoSpentStatus(
  baseUrl: string,
  utxos: ReadonlyArray<{ txid: string; vout: number }>,
  signal?: AbortSignal,
): Promise<Map<string, boolean>> {
  // Group requested vouts by txid so we only make one WS call per tx.
  const byTxid = new Map<string, Set<number>>();
  for (const u of utxos) {
    let vouts = byTxid.get(u.txid);
    if (!vouts) {
      vouts = new Set();
      byTxid.set(u.txid, vouts);
    }
    vouts.add(u.vout);
  }

  const result = new Map<string, boolean>();
  for (const [txid, vouts] of byTxid) {
    if (signal?.aborted) return result;
    try {
      const tx = await fetchBlockbookTransaction(baseUrl, txid, signal);
      const voutArr = Array.isArray(tx.vout) ? tx.vout : [];
      for (const v of voutArr) {
        if (typeof v.n !== 'number' || !vouts.has(v.n)) continue;
        if (typeof v.spent !== 'boolean') continue;
        result.set(`${txid}:${v.n}`, v.spent);
      }
    } catch (err) {
      // Best-effort: one failed lookup shouldn't sink the reconcile pass.
      // The unknown UTXO stays in place and gets retried next time.
      console.warn(`Failed to check spent status for ${txid}:`, err);
    }
  }
  return result;
}

// ---------------------------------------------------------------------------
// getInfo — health check
// ---------------------------------------------------------------------------

// ---------------------------------------------------------------------------
// getBlock — block-header timestamp lookup
// ---------------------------------------------------------------------------
//
// Used by the silent-payments orchestrator to stamp persisted SP UTXOs with
// the real block timestamp (instead of a synthetic estimate from block
// height × 600s, which drifts noticeably as cumulative average block time
// diverges from 10 minutes).
//
// We request `pageSize: 1` because we only need the block header — the full
// tx list can be megabytes for a busy block and we throw it away. Blockbook
// still returns `time` at the top level of the response either way.
// ---------------------------------------------------------------------------

interface BlockbookBlockResponse {
  /** Block height. */
  height?: unknown;
  /** Block timestamp in unix seconds. */
  time?: unknown;
}

/**
 * Fetch the unix-seconds timestamp of a block by height.
 *
 * Throws if the response is missing the `time` field — the wallet uses a
 * clamped synthetic estimate as a fallback so a transient lookup failure
 * doesn't break the UI.
 */
export async function fetchBlockTime(
  baseUrl: string,
  height: number,
  signal?: AbortSignal,
): Promise<number> {
  if (!Number.isInteger(height) || height < 0) {
    throw new Error(`Invalid block height: ${height}`);
  }
  const data = await getSocket(baseUrl).send<BlockbookBlockResponse>(
    'getBlock',
    { id: String(height), page: 1, pageSize: 1 },
    signal,
  );
  const t = data?.time;
  if (typeof t !== 'number' || !Number.isFinite(t) || t <= 0) {
    throw new Error(`Blockbook getBlock(${height}) missing valid \`time\``);
  }
  return Math.floor(t);
}

// ---------------------------------------------------------------------------
// getCurrentFiatRates — spot BTC → fiat exchange rate
// ---------------------------------------------------------------------------
//
// Blockbook tracks fiat rates for the coin it serves. The WS API takes a
// list of ISO currency codes and returns a `{ ts, rates: { [ccy]: number } }`
// payload. We use this so /wallet's USD display sources from the same
// server as its balance and tx data — no extra HTTP dependency on
// mempool.space.
// ---------------------------------------------------------------------------

interface BlockbookFiatRatesResponse {
  /** Unix seconds the rate snapshot was published. */
  ts?: number;
  /** ISO currency code → BTC/<ccy> exchange rate (BTC value of one unit). */
  rates?: Record<string, number>;
}

/**
 * Fetch the current BTC price in the requested fiat currency.
 *
 * @param baseUrl   Blockbook base URL (HTTP or WSS form).
 * @param currency  ISO currency code, lower-case (e.g. `'usd'`). Default `'usd'`.
 * @param signal    Optional abort signal.
 * @throws when the response is missing or doesn't include the requested currency.
 */
export async function fetchBlockbookBtcPrice(
  baseUrl: string,
  currency = 'usd',
  signal?: AbortSignal,
): Promise<number> {
  const data = await getSocket(baseUrl).send<BlockbookFiatRatesResponse>(
    'getCurrentFiatRates',
    { currencies: [currency] },
    signal,
  );
  const rate = data?.rates?.[currency];
  if (typeof rate !== 'number' || !Number.isFinite(rate) || rate <= 0) {
    throw new Error(`Blockbook fiat rate for "${currency}" unavailable`);
  }
  return rate;
}
