// Esplora REST client — the chain data source for the native on-chain wallet backend.
//
// Zeus's embedded backends (EmbeddedLND, LdkNode) get chain data from a node bound to a React Native
// native module. Nothing about that ports to a Bun server process, so the server-side wallet reads the
// chain over Esplora's HTTP API instead: mempool.space, blockstream.info, or a self-hosted esplora.
// Every call is a plain unauthenticated GET, which is why the backend can serve watch-only requests
// without ever touching key material.
//
// Two Esplora endpoints are NOT JSON and are handled explicitly below:
//   GET  /tx/{txid}/hex  → the raw transaction as a bare hex string
//   POST /tx             → request body is bare hex, response body is the bare txid
// Everything else is JSON, and every wire shape it returns is typed in this file.

import { BackendError, type BitcoinNetwork, type FeeEstimates } from './types';

// ── configuration ────────────────────────────────────────────────────────────────────────────────

export type EsploraConfig = {
  /** Base URL of the Esplora API root, e.g. `https://mempool.space/api`. Trailing slashes are trimmed. */
  baseUrl: string;
  network: BitcoinNetwork;
  timeoutMs?: number;
};

const DEFAULT_TIMEOUT_MS = 20_000;

// ── wire shapes ──────────────────────────────────────────────────────────────────────────────────

/** Confirmation status shared by /tx, /address/{a}/txs and /address/{a}/utxo. */
export type EsploraTxStatus = {
  confirmed: boolean;
  block_height?: number;
  block_hash?: string;
  /** Unix seconds of the containing block. Absent while unconfirmed. */
  block_time?: number;
};

/** Aggregate funded/spent counters. Esplora reports one set for the chain and one for the mempool. */
export type EsploraAddressStats = {
  funded_txo_count: number;
  funded_txo_sum: number;
  spent_txo_count: number;
  spent_txo_sum: number;
  tx_count: number;
};

export type EsploraAddress = {
  address: string;
  chain_stats: EsploraAddressStats;
  mempool_stats: EsploraAddressStats;
};

export type EsploraVout = {
  /** scriptPubKey, hex. */
  scriptpubkey: string;
  scriptpubkey_asm: string;
  /** Esplora's own classification: 'v0_p2wpkh' | 'v1_p2tr' | 'p2pkh' | 'p2sh' | 'op_return' | … */
  scriptpubkey_type: string;
  scriptpubkey_address?: string;
  value: number;
};

export type EsploraVin = {
  txid: string;
  vout: number;
  /** The output being spent. Null for coinbase inputs. */
  prevout: EsploraVout | null;
  scriptsig: string;
  scriptsig_asm: string;
  witness?: string[];
  is_coinbase: boolean;
  sequence: number;
  inner_redeemscript_asm?: string;
  inner_witnessscript_asm?: string;
};

export type EsploraTx = {
  txid: string;
  version: number;
  locktime: number;
  vin: EsploraVin[];
  vout: EsploraVout[];
  size: number;
  weight: number;
  /** Absolute fee in sats. Zero for coinbase. */
  fee: number;
  status: EsploraTxStatus;
};

export type EsploraUtxo = {
  txid: string;
  vout: number;
  value: number;
  status: EsploraTxStatus;
};

/** /fee-estimates: confirmation target (in blocks, as a string key) → sat/vB, as a float. */
export type EsploraFeeEstimates = Record<string, number>;

// ── fee mapping ──────────────────────────────────────────────────────────────────────────────────

// Esplora publishes an estimate per confirmation target; FeeEstimates in types.ts is mempool.space's
// five named tiers. These are the targets each tier maps onto.
const FEE_TARGETS = {
  fastestFee: 1,
  halfHourFee: 3,
  hourFee: 6,
  economyFee: 144,
  minimumFee: 1008,
} as const satisfies Record<keyof FeeEstimates, number>;

/** Nothing below the default minimum relay fee will propagate, so 1 sat/vB is the hard floor. */
const MIN_SAT_VB = 1;

/**
 * Pick the estimate for a confirmation target. Esplora only publishes some targets (1..25, then 144,
 * 504, 1008), so when the exact key is missing we take the largest published target *at or below* the
 * one asked for — a shorter target always quotes a higher rate, so this errs towards confirming.
 */
function pickForTarget(raw: EsploraFeeEstimates, target: number): number | null {
  const exact = raw[String(target)];
  if (typeof exact === 'number' && Number.isFinite(exact)) return exact;

  let best: number | null = null;
  let bestKey = -1;
  for (const [key, rate] of Object.entries(raw)) {
    const k = Number(key);
    if (!Number.isFinite(k) || !Number.isFinite(rate)) continue;
    if (k <= target && k > bestKey) {
      bestKey = k;
      best = rate;
    }
  }
  return best;
}

/**
 * Map Esplora's target→rate map onto the five named tiers. Exported separately from the client so the
 * mapping can be unit-tested against a captured /fee-estimates payload with no network involved.
 */
export function mapFeeEstimates(raw: EsploraFeeEstimates): FeeEstimates {
  const at = (target: number): number => Math.max(MIN_SAT_VB, Math.ceil(pickForTarget(raw, target) ?? MIN_SAT_VB));

  const fastestFee = at(FEE_TARGETS.fastestFee);
  // A tier must never quote more than a shorter-target tier: some esplora deployments serve a stale or
  // partial map where a longer target reads higher, and a UI that shows "1 hour" above "fastest" is a
  // bug report. Clamp each tier down to its faster neighbour.
  const halfHourFee = Math.min(fastestFee, at(FEE_TARGETS.halfHourFee));
  const hourFee = Math.min(halfHourFee, at(FEE_TARGETS.hourFee));
  const economyFee = Math.min(hourFee, at(FEE_TARGETS.economyFee));
  const minimumFee = Math.min(economyFee, at(FEE_TARGETS.minimumFee));

  return { fastestFee, halfHourFee, hourFee, economyFee, minimumFee };
}

// ── the client ───────────────────────────────────────────────────────────────────────────────────

type RequestInitLite = {
  method?: 'GET' | 'POST';
  /** Sent verbatim as the body — Esplora's POST /tx takes bare hex, not JSON. */
  body?: string;
  contentType?: string;
};

export class EsploraChain {
  readonly network: BitcoinNetwork;
  private readonly baseUrl: string;
  private readonly timeoutMs: number;

  constructor(cfg: EsploraConfig) {
    this.baseUrl = cfg.baseUrl.replace(/\/+$/, '');
    this.network = cfg.network;
    this.timeoutMs = cfg.timeoutMs ?? DEFAULT_TIMEOUT_MS;
  }

  /** Every request goes through here so timeouts and upstream errors become one BackendError shape. */
  private async text(path: string, init: RequestInitLite = {}): Promise<string> {
    const url = `${this.baseUrl}${path.startsWith('/') ? path : `/${path}`}`;
    const controller = new AbortController();
    const timer = setTimeout(() => controller.abort(), this.timeoutMs);

    let res: Response;
    try {
      res = await fetch(url, {
        method: init.method ?? 'GET',
        body: init.body,
        headers: init.contentType ? { 'Content-Type': init.contentType } : undefined,
        signal: controller.signal,
      });
    } catch (err) {
      const msg = controller.signal.aborted ? `timed out after ${this.timeoutMs}ms` : String(err);
      throw new BackendError(`esplora request ${path} failed: ${msg}`, 502, 'UPSTREAM_UNREACHABLE');
    } finally {
      clearTimeout(timer);
    }

    const body = await res.text();
    if (!res.ok) {
      // Esplora reports every failure as plain text: 'Transaction not found', 'sendrawtransaction RPC
      // error: {"code":-26,...}'. There is no JSON envelope to unwrap, so the text is the message.
      throw new BackendError(`esplora ${res.status} on ${path}: ${body.slice(0, 300)}`, res.status, 'ESPLORA_ERROR');
    }
    return body;
  }

  private async json<T>(path: string): Promise<T> {
    const body = await this.text(path);
    try {
      return JSON.parse(body) as T;
    } catch {
      throw new BackendError(`esplora returned non-JSON on ${path}: ${body.slice(0, 200)}`, 502, 'ESPLORA_ERROR');
    }
  }

  // ── endpoints ──────────────────────────────────────────────────────────────────────────────────

  /** GET /address/{addr} — funded/spent counters for the chain and the mempool. */
  getAddress(address: string): Promise<EsploraAddress> {
    return this.json<EsploraAddress>(`/address/${encodeURIComponent(address)}`);
  }

  /**
   * GET /address/{addr}/txs — the newest ~50 transactions (all mempool ones, then 25 confirmed).
   * Pass `afterTxid` to page further back through confirmed history via /txs/chain/{last_seen_txid}.
   */
  getAddressTxs(address: string, afterTxid?: string): Promise<EsploraTx[]> {
    const addr = encodeURIComponent(address);
    const path = afterTxid ? `/address/${addr}/txs/chain/${encodeURIComponent(afterTxid)}` : `/address/${addr}/txs`;
    return this.json<EsploraTx[]>(path);
  }

  /** GET /address/{addr}/utxo — unspent outputs, confirmed and unconfirmed. */
  getAddressUtxos(address: string): Promise<EsploraUtxo[]> {
    return this.json<EsploraUtxo[]>(`/address/${encodeURIComponent(address)}/utxo`);
  }

  /** GET /tx/{txid}. */
  getTx(txid: string): Promise<EsploraTx> {
    return this.json<EsploraTx>(`/tx/${encodeURIComponent(txid)}`);
  }

  /** GET /tx/{txid}/hex — plain text, not JSON. Needed as `nonWitnessUtxo` for legacy p2pkh inputs. */
  async getTxHex(txid: string): Promise<string> {
    const hex = (await this.text(`/tx/${encodeURIComponent(txid)}/hex`)).trim();
    if (!/^[0-9a-fA-F]+$/.test(hex)) {
      throw new BackendError(`esplora returned a non-hex body for tx ${txid}`, 502, 'ESPLORA_ERROR');
    }
    return hex;
  }

  /** GET /blocks/tip/height — plain integer in the body. */
  async getTipHeight(): Promise<number> {
    const body = (await this.text('/blocks/tip/height')).trim();
    const height = Number(body);
    if (!Number.isInteger(height) || height < 0) {
      throw new BackendError(`esplora returned a bad tip height: ${body.slice(0, 60)}`, 502, 'ESPLORA_ERROR');
    }
    return height;
  }

  /** GET /fee-estimates, folded onto the five named tiers. */
  async getFeeEstimates(): Promise<FeeEstimates> {
    return mapFeeEstimates(await this.json<EsploraFeeEstimates>('/fee-estimates'));
  }

  /**
   * POST /tx — the body is the raw transaction hex with no JSON wrapper, and the 200 response body is
   * the bare txid. A rejection comes back as a 400 with bitcoind's `sendrawtransaction RPC error` text,
   * which `text()` above surfaces verbatim; that message is the only diagnosis a caller gets.
   */
  async broadcast(rawHex: string): Promise<string> {
    const txid = (await this.text('/tx', { method: 'POST', body: rawHex, contentType: 'text/plain' })).trim();
    if (!/^[0-9a-fA-F]{64}$/.test(txid)) {
      throw new BackendError(`broadcast did not return a txid: ${txid.slice(0, 200)}`, 502, 'ESPLORA_ERROR');
    }
    return txid;
  }
}

/** Alias kept for call sites that read better as a client than as a chain source. */
export { EsploraChain as EsploraClient };
