// The NBXplorer chain source: the owner's own bitcoind, read through NBXplorer's wallet-level index.
//
// The contrast with chain-source-esplora.ts is the whole reason the WalletChainSource seam exists. There
// is no gap-limit walk here and no address fan-out, because NBXplorer already knows what the wallet owns:
// the account xpub is registered once, and after that a scan is three calls per account regardless of how
// many addresses have been used. What that costs instead is reassembly — NBXplorer answers wallet-wide,
// so the per-address view the rest of the wallet expects has to be rebuilt from UTXO key paths.
//
// REGISTRATION IS NOT RECOVERY, and this is the trap: a newly tracked xpub only picks up activity from
// the moment it is registered. An imported seed therefore reads as a real, quiet, empty wallet — every
// call succeeds, nothing errors, and the coins are simply not in the index. `startRescan` below is the
// answer for balance and spending: `scantxoutset` walks the node's current UTXO set directly, so it finds
// coins regardless of when the account was registered or how far back the node is pruned.
//
// What a rescan does NOT recover is spent-transaction history predating registration; that is a block
// rescan, a much heavier thing, and this source will keep reporting it as absent rather than as an error.
// Esplora stays available for the owner who wants the full history back.

import * as bitcoin from 'bitcoinjs-lib';
import {
  type AddressEntry,
  type ChainIndex,
  type RescanHandle,
  type ScanAccount,
  type ScanContext,
  type ScannedAddress,
  type ScanResult,
  type WalletChainSource,
} from './chain-source';
import { derivationScheme, type NbxplorerChain, type NbxTransaction, type NbxUtxo } from './nbxplorer';
import type { SpendableUtxo } from './psbt';
import { networkFor } from './psbt';
import {
  BackendError,
  type AddressType,
  type BitcoinNetwork,
  type FeeEstimates,
  type OnchainTx,
  type RescanOptions,
  type RescanState,
} from './types';

/** Confirmation targets, in blocks, behind the five fee tiers the wallet reports. */
const FEE_TARGETS = { fastestFee: 1, halfHourFee: 3, hourFee: 6, economyFee: 25, minimumFee: 144 } as const;

/** How often to ask how a `scantxoutset` is going. Frequent enough that a finished result is never missed. */
const SCAN_POLL_MS = 2_000;

/** Per-variant ceiling. A scan of a large UTXO set is minutes; anything past this is a wedged node. */
const SCAN_TIMEOUT_MS = 10 * 60_000;

/** Consecutive 404s tolerated before a variant has ever reported a status — see `scanVariant`. */
const SCAN_NULL_GRACE = 5;

const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));

export type NbxplorerChainSourceOptions = { chain: NbxplorerChain; network: BitcoinNetwork; label: string };

export class NbxplorerChainSource implements WalletChainSource {
  readonly label: string;

  /**
   * A scan reports up to NBXplorer's unused mark, but NBXplorer itself watches a gap beyond it
   * (`MinGapSize`, 20 by default) and extends as addresses are used, so an address a little past the
   * mark is still seen. Deliberately below that default rather than equal to it, because the setting
   * belongs to the node operator and is not readable from here — and unlike Esplora, a miss on this
   * source is recoverable: `utxos/scan` walks the node's whole UTXO set to a gap limit of 1000.
   */
  readonly issueAhead = 10;

  private readonly chain: NbxplorerChain;
  private readonly btcNetwork: bitcoin.Network;

  /**
   * Accounts seen by the most recent scan.
   *
   * `getTxHex` is the reason this exists: NBXplorer has no global transaction lookup, only a per-scheme
   * one, but the WalletChainSource signature carries a txid and nothing else. Every caller of getTxHex
   * runs inside sendCoins, which forces a fresh scan before it selects a single coin, so the map is
   * always populated by the time it is read. It is a memo of the last scan, never a source of truth.
   */
  private accounts: readonly ScanAccount[] = [];

  /** The rescan in flight, or the last one's outcome. Mutated in place as the scan progresses. */
  private rescan: RescanState | null = null;
  private rescanRun: Promise<RescanState> | null = null;

  constructor(opts: NbxplorerChainSourceOptions) {
    this.chain = opts.chain;
    this.btcNetwork = networkFor(opts.network);
    this.label = opts.label;
  }

  async getTipHeight(): Promise<number> {
    const status = await this.chain.getStatus();
    return status.chainHeight;
  }

  /**
   * NBXplorer answers one confirmation target per call, so the five tiers are five requests.
   *
   * In a quiet mempool every target returns the same number and the tiers collapse to one value. That is
   * the node telling the truth, not a bug — there is genuinely no fee premium worth paying when the next
   * block will clear the queue. The clamp only enforces that a slower target is never quoted higher than
   * a faster one, which estimatesmartfee can otherwise produce at the boundaries.
   */
  async getFeeEstimates(): Promise<FeeEstimates> {
    const entries = Object.entries(FEE_TARGETS) as [keyof FeeEstimates, number][];
    const rates = await Promise.all(entries.map(([, blocks]) => this.chain.getFeeRate(blocks)));

    const out = {} as FeeEstimates;
    let ceiling = Number.POSITIVE_INFINITY;
    for (let i = 0; i < entries.length; i++) {
      const entry = entries[i];
      const rate = rates[i];
      if (!entry || rate === undefined) continue;
      ceiling = Math.min(ceiling, rate);
      out[entry[0]] = ceiling;
    }
    return out;
  }

  async getTxHex(txid: string): Promise<string> {
    if (this.accounts.length === 0) {
      throw new BackendError('nbxplorer source was asked for a transaction before any scan', 500, 'NO_SCAN');
    }
    // Which account funded the input is not known here, and asking the wrong one 404s rather than
    // returning something wrong, so trying each in turn is safe. In practice the first one answers.
    let last: unknown = null;
    for (const account of this.accounts) {
      try {
        return await this.chain.getTxHex(account.accountXpub, account.type, txid);
      } catch (err) {
        last = err;
      }
    }
    throw last instanceof Error ? last : new BackendError(`no account knows transaction ${txid}`, 502);
  }

  async broadcast(rawHex: string): Promise<string> {
    await this.chain.broadcast(rawHex);
    // NBXplorer's broadcast returns only success/failure, so the txid is computed locally. It is a hash
    // of the bytes we just sent, so it cannot disagree with what was accepted.
    return bitcoin.Transaction.fromHex(rawHex).getId();
  }

  async scan(ctx: ScanContext): Promise<ScanResult> {
    this.accounts = ctx.accounts;

    const perAccount = await Promise.all(ctx.accounts.map((account) => this.scanAccount(ctx, account)));

    const tipHeight = Math.max(0, ...perAccount.map((r) => r.tipHeight));
    const addresses = perAccount.flatMap((r) => r.addresses);
    const utxos = perAccount.flatMap((r) => r.utxos);

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

    const txs = perAccount
      .flatMap((r) => r.txs)
      .map((tx) => this.toOnchainTx(tx, byScript, tipHeight))
      .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 { tipHeight, addresses, utxos, txs };
  }

  rescanState(): RescanState | null {
    return this.rescan;
  }

  /**
   * Run `scantxoutset` over every account variant, one at a time.
   *
   * ALL FOUR VARIANTS, ALWAYS. A recovered seed's coins can sit on any script type, and nothing here can
   * tell which until the node has looked — the p2sh account of a wallet that only ever used native segwit
   * costs one wasted scan, whereas skipping it costs the owner their money. The node serialises
   * `scantxoutset` anyway, so scanning them sequentially is what happens regardless of what we ask for;
   * doing it explicitly is what makes `done`/`total` mean something on screen.
   *
   * A second call while one is running returns the same handle rather than queueing a duplicate.
   */
  startRescan(accounts: readonly ScanAccount[], opts: RescanOptions = {}): RescanHandle {
    const existing = this.rescan;
    if (existing?.running && this.rescanRun) return { state: existing, done: this.rescanRun };

    const state: RescanState = {
      running: true,
      done: 0,
      total: accounts.length,
      current: accounts[0]?.type ?? null,
      found: 0,
      startedAt: Date.now(),
      finishedAt: null,
      error: null,
    };
    this.rescan = state;
    this.rescanRun = this.runRescan(accounts, state, opts);
    return { state, done: this.rescanRun };
  }

  /**
   * Pick up a scan that survived a restart of this sidecar.
   *
   * `scantxoutset` runs on the node and outlives us; only the handle to it was ever in memory. So the
   * upstream is asked directly, and if anything is Queued or Pending the run is rebuilt around the same
   * polling loop with starting suppressed — a duplicate POST would sit behind the real scan for its whole
   * duration, since the node runs one at a time.
   *
   * `found` restarts from zero and a variant that finished before the restart contributes nothing to it:
   * its result has expired upstream and there is no way to recover the count. The number under-reports
   * for that one run, which is the honest answer — and the refresh chained on the end is what actually
   * puts the coins on screen.
   */
  async adoptRescan(accounts: readonly ScanAccount[]): Promise<RescanHandle | null> {
    if (this.rescan?.running && this.rescanRun) return { state: this.rescan, done: this.rescanRun };

    const live = await Promise.all(
      accounts.map(async (account) => {
        // A probe failing is not an answer either way, and must not be reported as "a scan is running".
        const status = await this.chain.getUtxoScanStatus(account.accountXpub, account.type).catch(() => null);
        return status?.status === 'Queued' || status?.status === 'Pending';
      }),
    );
    if (!live.some(Boolean)) return null;

    const state: RescanState = {
      running: true,
      done: 0,
      total: accounts.length,
      current: accounts[0]?.type ?? null,
      found: 0,
      startedAt: Date.now(),
      finishedAt: null,
      error: null,
    };
    this.rescan = state;
    this.rescanRun = this.runRescan(accounts, state, null);
    return { state, done: this.rescanRun };
  }

  /** `opts` null means adopt: poll the variants without ever queueing one. */
  private async runRescan(
    accounts: readonly ScanAccount[],
    state: RescanState,
    opts: RescanOptions | null,
  ): Promise<RescanState> {
    try {
      for (const account of accounts) {
        state.current = account.type;
        state.found += opts ? await this.scanVariant(account, opts) : await this.awaitVariant(account);
        state.done += 1;
      }
    } catch (err) {
      // One variant failing stops the run, and the partial `found` stays on the state: coins already
      // pulled in by an earlier variant are really there, and reporting zero would understate the wallet.
      state.error = err instanceof Error ? err.message : String(err);
    } finally {
      state.running = false;
      state.current = null;
      state.finishedAt = Date.now();
    }
    return state;
  }

  /** Queue one variant's scan and wait it out. Resolves with how many UTXOs it pulled in. */
  private async scanVariant(account: ScanAccount, opts: RescanOptions): Promise<number> {
    await this.chain.startUtxoScan(account.accountXpub, account.type, { gapLimit: opts.gapLimit });
    return this.awaitVariant(account);
  }

  /**
   * Wait out a scan that is already queued upstream, without starting one.
   *
   * Split out of scanVariant for adoption: a scan this process did not start must never be re-queued,
   * because scantxoutset is single-threaded node-wide and a duplicate would sit behind the real one for
   * its whole duration. An account that already finished answers 404 or Complete and returns at once.
   */
  private async awaitVariant(account: ScanAccount): Promise<number> {
    const { accountXpub, type } = account;

    // The timeout bounds how long a scan may RUN, not how long it may wait its turn. scantxoutset is
    // single-threaded node-wide, so four variants queue behind each other and the last one could spend
    // most of its ten minutes not started — timing out while the node was never asked to do anything
    // wrong. The deadline is therefore pushed forward for as long as the status reads Queued.
    let deadline = Date.now() + SCAN_TIMEOUT_MS;
    let found = 0;
    let seen = false;
    let nulls = 0;

    while (Date.now() < deadline) {
      await sleep(SCAN_POLL_MS);
      const status = await this.chain.getUtxoScanStatus(accountXpub, type);

      // 404 means "no scan is running" — which before the first sighting is a race with the queue, and
      // after it means the result has expired. Only the second reading is a finish, hence the flag; the
      // grace bounds the first so a scan the node silently dropped does not hold the run for ten minutes.
      if (!status) {
        if (seen) return found;
        if (++nulls >= SCAN_NULL_GRACE) return found;
        continue;
      }

      seen = true;
      nulls = 0;
      found = status.progress?.found ?? found;

      if (status.status === 'Queued') deadline = Date.now() + SCAN_TIMEOUT_MS;
      if (status.status === 'Complete') return found;
      if (status.status === 'Error') {
        throw new BackendError(`utxo scan of the ${type} account failed: ${status.error ?? 'unknown'}`, 502, 'SCAN');
      }
    }

    throw new BackendError(`utxo scan of the ${type} account did not finish in time`, 504, 'SCAN_TIMEOUT');
  }

  private async scanAccount(ctx: ScanContext, account: ScanAccount) {
    const { accountXpub, type } = account;
    // track() is idempotent and every one of these calls makes it first, so registration cannot be
    // skipped — which matters because an unregistered scheme answers 200-with-zeros, not 404.
    const [utxoChanges, history, unusedDeposit, unusedChange] = await Promise.all([
      this.chain.getUtxos(accountXpub, type),
      this.chain.getTransactions(accountXpub, type),
      this.chain.getUnusedAddress(accountXpub, type, 'Deposit'),
      this.chain.getUnusedAddress(accountXpub, type, 'Change'),
    ]);

    const tipHeight = utxoChanges.currentHeight;

    // A coin already being spent by something in the mempool is not spendable again. NBXplorer still
    // lists it as a confirmed UTXO, so it has to be excluded explicitly or coin selection would build a
    // transaction that double-spends the owner's own unconfirmed one.
    const spentUnconfirmed = new Set(utxoChanges.spentUnconfirmed.map((u) => u.outpoint));

    const confirmed = utxoChanges.confirmed.utxOs.filter((u) => !spentUnconfirmed.has(u.outpoint));
    const unconfirmed = utxoChanges.unconfirmed.utxOs.filter((u) => !spentUnconfirmed.has(u.outpoint));

    // The per-address view the rest of the wallet works in. NBXplorer's own answer to "what is unused" is
    // taken as authoritative for where each chain ends — everything below that index has been seen, which
    // is what makes it unsafe to hand out again.
    const highest: Record<ChainIndex, number> = {
      0: indexOf(unusedDeposit.keyPath),
      1: indexOf(unusedChange.keyPath),
    };
    for (const u of [...confirmed, ...unconfirmed, ...utxoChanges.spentUnconfirmed]) {
      const at = parseKeyPath(u.keyPath);
      if (at) highest[at.chain] = Math.max(highest[at.chain], at.index);
    }

    const sats = new Map<string, { confirmed: number; unconfirmed: number }>();
    const bump = (u: NbxUtxo, field: 'confirmed' | 'unconfirmed', sign: 1 | -1) => {
      const cur = sats.get(u.keyPath) ?? { confirmed: 0, unconfirmed: 0 };
      cur[field] += sign * u.value;
      sats.set(u.keyPath, cur);
    };
    for (const u of confirmed) bump(u, 'confirmed', 1);
    for (const u of unconfirmed) bump(u, 'unconfirmed', 1);
    // Matches Esplora's signed mempool delta: an unconfirmed spend of a confirmed coin reads negative,
    // which is exactly what the Balances contract wants in onchainUnconfirmed.
    for (const u of utxoChanges.spentUnconfirmed) bump(u, 'unconfirmed', -1);

    const addresses: ScannedAddress[] = [];
    for (const chain of [0, 1] as ChainIndex[]) {
      for (let index = 0; index <= highest[chain]; index++) {
        const entry = ctx.derive(type, chain, index);
        const held = sats.get(`${chain}/${index}`);
        addresses.push({
          ...entry,
          confirmedSats: held?.confirmed ?? 0,
          unconfirmedSats: held?.unconfirmed ?? 0,
          // Below the unused mark means NBXplorer has seen this address; the mark itself has not.
          txCount: index < highest[chain] ? 1 : 0,
          used: index < highest[chain],
        });
      }
    }

    const utxos = [...confirmed, ...unconfirmed].flatMap<SpendableUtxo>((u) => {
      const at = parseKeyPath(u.keyPath);
      if (!at) return [];
      const entry = ctx.derive(type, at.chain, at.index);
      return [
        {
          txid: u.transactionHash,
          vout: u.index,
          amountSats: u.value,
          address: entry.address,
          addressType: entry.type,
          confirmations: Math.max(0, u.confirmations),
          derivationPath: entry.path,
          frozen: false,
          scriptPubKeyHex: entry.scriptPubKeyHex,
          pubkeyHex: entry.pubkeyHex,
        },
      ];
    });

    const txs = [...history.confirmedTransactions.transactions, ...history.unconfirmedTransactions.transactions];
    return { tipHeight, addresses, utxos, txs };
  }

  /**
   * NBXplorer already scores the transaction for us — `balanceChange` is signed and wallet-wide, which is
   * precisely what the Esplora source computes by hand from credits and debits.
   *
   * The fee is NOT recoverable here, and reporting a wrong one would be worse than reporting none: the
   * response carries no input values, and fetching every prevout to derive it would reintroduce the
   * per-transaction fan-out this source exists to avoid. The contract permits null.
   */
  private toOnchainTx(tx: NbxTransaction, byScript: Map<string, ScannedAddress>, tipHeight: number): OnchainTx {
    const amount = tx.balanceChange;
    const ours: string[] = [];
    const theirs: string[] = [];

    const hex = tx.transaction?.trim();
    if (hex) {
      try {
        for (const out of bitcoin.Transaction.fromHex(hex).outs) {
          const mine = byScript.get(out.script.toString('hex'));
          if (mine) {
            ours.push(mine.address);
            continue;
          }
          try {
            theirs.push(bitcoin.address.fromOutputScript(out.script, this.btcNetwork));
          } catch {
            // OP_RETURN and other unspendable scripts have no address. Not an error, just not a payee.
          }
        }
      } catch {
        // A transaction we cannot parse still has a correct amount and height; only the payee list is
        // lost, and an empty one renders as "unknown" rather than as something false.
      }
    }

    const height = tx.confirmations > 0 ? (tx.height ?? null) : null;
    return {
      txid: tx.transactionId,
      amount,
      feeSats: null,
      blockHeight: height,
      timestamp: tx.timestamp,
      confirmations: Math.max(0, tx.confirmations),
      label: null,
      destAddresses: amount < 0 ? (theirs.length > 0 ? theirs : ours) : ours,
      rawHex: null,
    };
  }
}

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

/** `"0/7"` → `{ chain: 0, index: 7 }`. Anything else is not an address this wallet derives. */
function parseKeyPath(keyPath: string): { chain: ChainIndex; index: number } | null {
  const parts = keyPath.split('/');
  const chain = Number(parts.at(-2));
  const index = Number(parts.at(-1));
  if ((chain !== 0 && chain !== 1) || !Number.isInteger(index) || index < 0) return null;
  return { chain, index };
}

/** The address index out of a key path, or 0 when it is unreadable. */
function indexOf(keyPath: string): number {
  return parseKeyPath(keyPath)?.index ?? 0;
}
