// The Esplora chain source: everything that used to be the on-chain backend's scanning half.
//
// Esplora answers about ONE address at a time and has no concept of a wallet, so finding the wallet is
// this file's job: derive addresses, ask about each, and stop after enough consecutive misses. That walk
// is why the file exists, and why it is the expensive source of the two.

import type { EsploraAddress, EsploraChain, EsploraTx } from './chain';
import type { SpendableUtxo } from './psbt';
import {
  mapLimit,
  MAX_SCAN_INDEX,
  REQUEST_CONCURRENCY,
  type AddressEntry,
  type ChainIndex,
  type ScanContext,
  type ScannedAddress,
  type ScanResult,
  type WalletChainSource,
} from './chain-source';
import type { AddressType, FeeEstimates, OnchainTx } from './types';

/** BIP44's standard gap limit: 20 consecutive unused addresses ends the scan for a chain. */
const GAP_LIMIT = 20;

/** Cap on the history returned. A wallet with years of activity should not grow an unbounded blob. */
const MAX_CACHED_TXS = 500;

/** The scan proper, before the coins and history that hang off it. */
type AddressScan = {
  tipHeight: number;
  addresses: ScannedAddress[];
  /** scriptPubKey hex → the entry that owns it. Used to score transactions as ours. */
  byScript: Map<string, ScannedAddress>;
};

export type EsploraChainSourceOptions = { chain: EsploraChain; label: string };

export class EsploraChainSource implements WalletChainSource {
  readonly label: string;

  /** The walk already runs a full GAP_LIMIT past the last used address, and stops dead there. */
  readonly issueAhead = 0;

  private readonly chain: EsploraChain;

  constructor(opts: EsploraChainSourceOptions) {
    this.chain = opts.chain;
    this.label = opts.label;
  }

  getTipHeight(): Promise<number> {
    return this.chain.getTipHeight();
  }

  getFeeEstimates(): Promise<FeeEstimates> {
    return this.chain.getFeeEstimates();
  }

  getTxHex(txid: string): Promise<string> {
    return this.chain.getTxHex(txid);
  }

  broadcast(rawHex: string): Promise<string> {
    return this.chain.broadcast(rawHex);
  }

  async scan(ctx: ScanContext): Promise<ScanResult> {
    const scan = await this.runScan(ctx);
    // Both fan out over the scanned address set and neither depends on the other, so they overlap.
    const [utxos, txs] = await Promise.all([this.collectUtxos(scan), this.collectTxs(scan)]);
    return { tipHeight: scan.tipHeight, addresses: scan.addresses, utxos, txs };
  }

  private async runScan(ctx: ScanContext): Promise<AddressScan> {
    const tipHeight = await this.chain.getTipHeight();
    const addresses: ScannedAddress[] = [];

    // A wallet holds an account xpub for all four BIP purposes, but a full gap-limit walk of every one
    // costs 4 types x 2 chains x 20 addresses = 160 requests, which trips the rate limit on every public
    // Esplora instance. Only the wallet's own script type is walked unconditionally; the others are
    // probed at receive index 0 first (one request each) and walked only if that address has ever been
    // used. A freshly generated seed therefore costs 43 requests instead of 160, while a seed recovered
    // from a wallet that used a different script type is still found rather than silently reported empty.
    for (const account of ctx.accounts) {
      if (account.type !== ctx.defaultType && !(await this.hasHistory(ctx, account.type))) continue;
      for (const chain of [0, 1] as ChainIndex[]) {
        addresses.push(...(await this.scanChain(ctx, account.type, chain)));
      }
    }

    const byScript = new Map<string, ScannedAddress>();
    for (const entry of addresses) byScript.set(entry.scriptPubKeyHex, entry);

    return { tipHeight, addresses, byScript };
  }

  /**
   * Has this script type ever been used at all? One request against receive index 0, which is the
   * address any wallet hands out first — so a used account is essentially never missed, and an unused
   * one costs a single call instead of a forty-address walk.
   */
  private async hasHistory(ctx: ScanContext, type: AddressType): Promise<boolean> {
    const entry = ctx.derive(type, 0, 0);
    return toScannedAddress(entry, await this.chain.getAddress(entry.address)).used;
  }

  /** Walk one (type, chain) pair a gap-limit window at a time until GAP_LIMIT consecutive misses. */
  private async scanChain(ctx: ScanContext, type: AddressType, chain: ChainIndex): Promise<ScannedAddress[]> {
    const found: ScannedAddress[] = [];
    let index = 0;
    let gap = 0;

    while (gap < GAP_LIMIT && index < MAX_SCAN_INDEX) {
      const window = Array.from({ length: GAP_LIMIT }, (_, i) => ctx.derive(type, chain, index + i));
      const stats = await mapLimit(window, REQUEST_CONCURRENCY, (entry) => this.chain.getAddress(entry.address));

      for (let i = 0; i < window.length; i++) {
        const entry = window[i];
        const stat = stats[i];
        if (!entry || !stat) continue;
        const scanned = toScannedAddress(entry, stat);
        found.push(scanned);
        gap = scanned.used ? 0 : gap + 1;
        if (gap >= GAP_LIMIT) break;
      }
      index += window.length;
    }

    return found;
  }

  /** Fetch UTXOs for every scanned address that still holds a balance. */
  private async collectUtxos(scan: AddressScan): Promise<SpendableUtxo[]> {
    // An address whose funded and spent counts match holds nothing; asking Esplora about it is a
    // wasted round trip, and on a wallet with long history that is most of the address set.
    const funded = scan.addresses.filter((a) => a.confirmedSats + a.unconfirmedSats > 0);
    const sets = await mapLimit(funded, REQUEST_CONCURRENCY, async (entry) => {
      const utxos = await this.chain.getAddressUtxos(entry.address);
      return utxos.map<SpendableUtxo>((u) => ({
        txid: u.txid,
        vout: u.vout,
        amountSats: u.value,
        address: entry.address,
        addressType: entry.type,
        confirmations:
          u.status.confirmed && u.status.block_height !== undefined
            ? Math.max(0, scan.tipHeight - u.status.block_height + 1)
            : 0,
        derivationPath: entry.path,
        frozen: false,
        scriptPubKeyHex: entry.scriptPubKeyHex,
        pubkeyHex: entry.pubkeyHex,
      }));
    });
    return sets.flat();
  }

  /** History for every address the scan found had been touched. Only those can appear in it. */
  private async collectTxs(scan: AddressScan): Promise<OnchainTx[]> {
    const touched = scan.addresses.filter((a) => a.used);
    const pages = await mapLimit(touched, REQUEST_CONCURRENCY, (a) => this.chain.getAddressTxs(a.address));

    const seen = new Map<string, EsploraTx>();
    for (const page of pages) {
      for (const tx of page) if (!seen.has(tx.txid)) seen.set(tx.txid, tx);
    }

    const txs = [...seen.values()].map((tx) => toOnchainTx(tx, scan));
    txs.sort((a, b) => {
      // Unconfirmed first (they have no height), then newest block, then newest timestamp.
      const ah = a.blockHeight ?? Number.MAX_SAFE_INTEGER;
      const bh = b.blockHeight ?? Number.MAX_SAFE_INTEGER;
      if (ah !== bh) return bh - ah;
      return (b.timestamp ?? 0) - (a.timestamp ?? 0);
    });
    return txs.slice(0, MAX_CACHED_TXS);
  }
}

// ── helpers ──────────────────────────────────────────────────────────────────────────────────────

function toScannedAddress(entry: AddressEntry, stat: EsploraAddress): ScannedAddress {
  const chainBalance = stat.chain_stats.funded_txo_sum - stat.chain_stats.spent_txo_sum;
  // The mempool delta is signed: an unconfirmed spend of a confirmed coin reads negative here, which is
  // exactly what the Balances contract wants in onchainUnconfirmed.
  const mempoolBalance = stat.mempool_stats.funded_txo_sum - stat.mempool_stats.spent_txo_sum;
  const txCount = stat.chain_stats.tx_count + stat.mempool_stats.tx_count;
  return {
    ...entry,
    confirmedSats: chainBalance,
    unconfirmedSats: mempoolBalance,
    txCount,
    used: txCount > 0,
  };
}

/** Score one Esplora transaction against the wallet's own scripts. */
function toOnchainTx(tx: EsploraTx, scan: AddressScan): OnchainTx {
  let credit = 0;
  let debit = 0;
  const ours: string[] = [];
  const theirs: string[] = [];

  for (const out of tx.vout) {
    const mine = scan.byScript.get(out.scriptpubkey);
    if (mine) {
      credit += out.value;
      ours.push(mine.address);
    } else if (out.scriptpubkey_address) {
      theirs.push(out.scriptpubkey_address);
    }
  }
  for (const input of tx.vin) {
    const prevout = input.prevout;
    if (prevout && scan.byScript.has(prevout.scriptpubkey)) debit += prevout.value;
  }

  const height = tx.status.confirmed ? (tx.status.block_height ?? null) : null;
  const confirmations = height === null ? 0 : Math.max(0, scan.tipHeight - height + 1);
  const amount = credit - debit;

  return {
    txid: tx.txid,
    amount,
    // The fee is only ours to report when we funded an input; for an incoming payment the sender
    // paid it and attributing it to this wallet would be a lie.
    feeSats: debit > 0 ? tx.fee : null,
    blockHeight: height,
    timestamp: tx.status.block_time ?? null,
    confirmations,
    // No label store in this backend; SendCoinsRequest.label is accepted and dropped.
    label: null,
    destAddresses: amount < 0 ? (theirs.length > 0 ? theirs : ours) : ours,
    // Fetching /tx/{txid}/hex per transaction would double the request count for a list view. The
    // contract permits null, and the raw hex is fetched on demand where it is actually needed.
    rawHex: null,
  };
}
