// Behavioural pins for the Esplora chain source, driven through a real OnchainBackend.
//
// These exist because the scan used to live inside the backend and was moved out wholesale; nothing in
// the suite covered it before, so a silent behaviour change during the move would have shipped. The stub
// below is a full stand-in for the HTTP client, which means the whole path — gap-limit walk, balance
// summation, UTXO mapping, transaction scoring, address issuance — runs with no network at all.

import { describe, expect, test } from 'bun:test';
import { HDKey } from '@scure/bip32';
import { mnemonicToSeedSync } from '@scure/bip39';
import * as bitcoin from 'bitcoinjs-lib';
import type { EsploraAddress, EsploraChain, EsploraTx, EsploraUtxo } from './chain';
import { EsploraChainSource } from './chain-source-esplora';
import { OnchainBackend, type WalletSigner } from './backends/onchain';
import { initEcc } from './psbt';

initEcc();

// NEVER PUT A REAL ACCOUNT KEY IN A TEST. An xpub cannot spend, but it reveals every address a wallet
// will ever use and its entire history, permanently and irrevocably — and a test file is the easiest
// place in a repo for one to hide, because it looks like a fixture. This one is derived here, in the
// open, from the BIP84 specification's own published mnemonic, so its provenance is checkable rather
// than asserted. If you need a different fixture, derive it the same way; do not paste one in.
const PUBLIC_TEST_MNEMONIC =
  'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';

const ACCOUNT_XPUB = HDKey.fromMasterSeed(mnemonicToSeedSync(PUBLIC_TEST_MNEMONIC)).derive(
  "m/84'/0'/0'",
).publicExtendedKey;

/** Derive the same p2wpkh address the backend will, independently of the backend. */
function addressAt(chain: 0 | 1, index: number): string {
  const node = HDKey.fromExtendedKey(ACCOUNT_XPUB).deriveChild(chain).deriveChild(index);
  const pubkey = Buffer.from(node.publicKey!);
  return bitcoin.payments.p2wpkh({ pubkey, network: bitcoin.networks.bitcoin }).address!;
}

function scriptAt(chain: 0 | 1, index: number): string {
  const node = HDKey.fromExtendedKey(ACCOUNT_XPUB).deriveChild(chain).deriveChild(index);
  const pubkey = Buffer.from(node.publicKey!);
  return bitcoin.payments.p2wpkh({ pubkey, network: bitcoin.networks.bitcoin }).output!.toString('hex');
}

const EMPTY_STATS = { funded_txo_count: 0, funded_txo_sum: 0, spent_txo_count: 0, spent_txo_sum: 0, tx_count: 0 };

type Funding = { confirmed?: number; spent?: number; mempool?: number; txCount?: number };

/**
 * A stand-in for EsploraChain that serves a fixed world: a map of address → what the chain says about it,
 * and everything else derived from that. Also counts requests, because the request count IS the behaviour
 * under test for the gap-limit walk.
 */
class StubEsplora {
  readonly calls: string[] = [];
  constructor(
    private readonly world: Map<string, Funding>,
    private readonly tip = 800_000,
  ) {}

  async getTipHeight(): Promise<number> {
    this.calls.push('tip');
    return this.tip;
  }

  async getAddress(address: string): Promise<EsploraAddress> {
    this.calls.push(`addr:${address}`);
    const f = this.world.get(address);
    if (!f) return { address, chain_stats: { ...EMPTY_STATS }, mempool_stats: { ...EMPTY_STATS } };
    return {
      address,
      chain_stats: {
        ...EMPTY_STATS,
        funded_txo_sum: f.confirmed ?? 0,
        spent_txo_sum: f.spent ?? 0,
        tx_count: f.txCount ?? 1,
      },
      mempool_stats: { ...EMPTY_STATS, funded_txo_sum: f.mempool ?? 0, tx_count: f.mempool ? 1 : 0 },
    };
  }

  async getAddressUtxos(address: string): Promise<EsploraUtxo[]> {
    this.calls.push(`utxo:${address}`);
    const f = this.world.get(address);
    const value = (f?.confirmed ?? 0) - (f?.spent ?? 0);
    if (!f || value <= 0) return [];
    return [{ txid: 'a'.repeat(64), vout: 0, value, status: { confirmed: true, block_height: this.tip - 5 } }];
  }

  async getAddressTxs(address: string): Promise<EsploraTx[]> {
    this.calls.push(`txs:${address}`);
    const f = this.world.get(address);
    if (!f) return [];
    const idx = [...this.world.keys()].indexOf(address);
    return [
      {
        txid: String(idx).padStart(64, 'b'),
        version: 2,
        locktime: 0,
        fee: 200,
        vin: [{ txid: 'c'.repeat(64), vout: 0, prevout: null, scriptsig: '', sequence: 0xffffffff, witness: [] }],
        vout: [
          { scriptpubkey: scriptFor(address), scriptpubkey_asm: '', scriptpubkey_type: 'v0_p2wpkh', value: 50_000 },
        ],
        status: { confirmed: true, block_height: this.tip - 5, block_time: 1_700_000_000 },
      } as unknown as EsploraTx,
    ];
  }

  async getFeeEstimates() {
    return { fastestFee: 10, halfHourFee: 8, hourFee: 5, economyFee: 2, minimumFee: 1 };
  }

  async getTxHex(): Promise<string> {
    throw new Error('not needed');
  }

  /** Accepts anything and reports a fixed txid — the spend tests care what got selected, not what got sent. */
  async broadcast(): Promise<string> {
    this.calls.push('broadcast');
    return 'd'.repeat(64);
  }
}

/** Reverse-lookup the script for an address the stub was configured with. */
function scriptFor(address: string): string {
  for (const chain of [0, 1] as const) {
    for (let i = 0; i < 40; i++) if (addressAt(chain, i) === address) return scriptAt(chain, i);
  }
  return 'ff';
}

const LOCKED_SIGNER: WalletSigner = {
  isUnlocked: () => false,
  withRoot: () => {
    throw new Error('locked');
  },
};

/**
 * A signer holding the fixture's own root key. Only the spend tests need one — everything else runs
 * locked, which is the state the wallet is in almost all of the time.
 */
const UNLOCKED_SIGNER: WalletSigner = {
  isUnlocked: () => true,
  withRoot: <T>(fn: (root: HDKey) => T): T => fn(HDKey.fromMasterSeed(mnemonicToSeedSync(PUBLIC_TEST_MNEMONIC))),
};

function backendFor(world: Map<string, Funding>, signer: WalletSigner = LOCKED_SIGNER) {
  const stub = new StubEsplora(world);
  const source = new EsploraChainSource({
    chain: stub as unknown as EsploraChain,
    label: 'esplora(stub)',
  });
  const backend = new OnchainBackend({
    chain: source,
    network: 'bitcoin',
    accountXpub: { p2wpkh: ACCOUNT_XPUB },
    signer,
  });
  return { backend, stub };
}

describe('EsploraChainSource through OnchainBackend', () => {
  test('the fixture key really is the published BIP84 vector, not somebody’s wallet', () => {
    // The spec's own first receive address. If this fails, the mnemonic above is not what it claims and
    // whatever replaced it must be treated as live key material until proven otherwise.
    expect(addressAt(0, 0)).toBe('bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu');
  });

  test('an empty wallet costs one gap-limit window per chain and reports zero', async () => {
    const { backend, stub } = backendFor(new Map());

    expect(await backend.getBalances()).toEqual({
      onchainConfirmed: 0,
      onchainUnconfirmed: 0,
      onchainFrozen: 0,
      lightningBalance: null,
      lightningInbound: null,
    });
    expect(await backend.getUtxos()).toEqual([]);
    expect(await backend.getTransactions()).toEqual([]);

    // One script type, two chains, one gap-limit window each: exactly 40 address lookups and no more.
    expect(stub.calls.filter((c) => c.startsWith('addr:'))).toHaveLength(40);
    // Nothing held a balance, so not a single UTXO call was worth making.
    expect(stub.calls.filter((c) => c.startsWith('utxo:'))).toHaveLength(0);
  });

  test('unused script types cost one probe each, not a full walk', async () => {
    // The same xpub under four script types: every one derives valid addresses, and only the default
    // (p2wpkh) should be walked. This is the optimisation that keeps a scan at 43 requests instead of 160.
    const stub = new StubEsplora(new Map());
    const backend = new OnchainBackend({
      chain: new EsploraChainSource({ chain: stub as unknown as EsploraChain, label: 'esplora(stub)' }),
      network: 'bitcoin',
      accountXpub: {
        p2wpkh: ACCOUNT_XPUB,
        p2tr: ACCOUNT_XPUB,
        'p2sh-p2wpkh': ACCOUNT_XPUB,
        p2pkh: ACCOUNT_XPUB,
      },
      signer: LOCKED_SIGNER,
    });

    await backend.getBalances();
    // 40 for the walked default + 1 probe each for the three that have never been used.
    expect(stub.calls.filter((c) => c.startsWith('addr:'))).toHaveLength(43);
  });

  test('balances sum the confirmed and mempool deltas across the scan', async () => {
    const world = new Map<string, Funding>([
      [addressAt(0, 0), { confirmed: 100_000, spent: 40_000 }],
      [addressAt(0, 3), { confirmed: 25_000 }],
      [addressAt(1, 0), { confirmed: 0, mempool: 7_000 }],
    ]);
    const { backend } = backendFor(world);

    const balances = await backend.getBalances();
    expect(balances.onchainConfirmed).toBe(85_000);
    expect(balances.onchainUnconfirmed).toBe(7_000);

    const utxos = await backend.getUtxos();
    expect(utxos.map((u) => u.amountSats).sort((a, b) => a - b)).toEqual([25_000, 60_000]);
    // The public contract reports the path relative to the account xpub, not the absolute one.
    expect(utxos.every((u) => /^[01]\/\d+$/.test(u.derivationPath ?? ''))).toBe(true);
    expect(utxos.every((u) => u.confirmations === 6)).toBe(true);
  });

  test('a used address resets the gap, so funds past the first window are still found', async () => {
    // Index 25 is beyond the initial 20-address window: it is only reachable if a hit at 19 reset the gap.
    const world = new Map<string, Funding>([
      [addressAt(0, 19), { confirmed: 1_000 }],
      [addressAt(0, 25), { confirmed: 2_000 }],
    ]);
    const { backend } = backendFor(world);

    expect((await backend.getBalances()).onchainConfirmed).toBe(3_000);
  });

  test('getNewAddress peeks without consuming and advances only when asked', async () => {
    const world = new Map<string, Funding>([[addressAt(0, 0), { confirmed: 5_000 }]]);
    const { backend } = backendFor(world);

    // 0/0 is used, so the first unused is 0/1 — and peeking twice must not move past it.
    expect((await backend.getNewAddress({ peek: true })).address).toBe(addressAt(0, 1));
    expect((await backend.getNewAddress({ peek: true })).address).toBe(addressAt(0, 1));

    expect((await backend.getNewAddress()).address).toBe(addressAt(0, 1));
    expect((await backend.getNewAddress()).address).toBe(addressAt(0, 2));
  });

  // Issuance used to be unbounded: the mark moved on every call, paid or not, so a run of unpaid
  // addresses walked it past the end of the window the next scan covers. A payment there would have been
  // invisible forever — Esplora has no rescan to go and look.
  test('issuance stops at the far edge of the scanned window instead of walking past it', async () => {
    const world = new Map<string, Funding>([[addressAt(0, 0), { confirmed: 5_000 }]]);
    const { backend } = backendFor(world);

    const issued: string[] = [];
    for (let i = 0; i < 30; i++) issued.push((await backend.getNewAddress()).address);

    // 0/0 is used, so the walk covers 0..20 — one full gap limit of misses past it — and stops.
    expect(issued[0]).toBe(addressAt(0, 1));
    expect(issued[19]).toBe(addressAt(0, 20));
    // Everything after that re-offers the last address the scan can still see. All of them are virgin,
    // so repeating one is not address reuse in the sense that loses privacy.
    expect(issued.at(-1)).toBe(addressAt(0, 20));
    const scanned = new Set(Array.from({ length: 21 }, (_, i) => addressAt(0, i)));
    expect(issued.filter((a) => !scanned.has(a))).toEqual([]);
  });

  test('transactions are scored against the wallet and credit is positive', async () => {
    const world = new Map<string, Funding>([[addressAt(0, 0), { confirmed: 50_000 }]]);
    const { backend } = backendFor(world);

    const txs = await backend.getTransactions();
    expect(txs).toHaveLength(1);
    const tx = txs[0]!;
    expect(tx.amount).toBe(50_000);
    expect(tx.confirmations).toBe(6);
    expect(tx.destAddresses).toEqual([addressAt(0, 0)]);
    // We funded no input, so the fee was the sender's and must not be attributed to this wallet.
    expect(tx.feeSats).toBeNull();
  });

  // Freezing a coin is a promise that it will not be spent. It was not being kept: the route checked the
  // frozen list only against outpoints the caller named explicitly, so coin control was safe while a
  // plain send — the ordinary case — picked inputs from a snapshot on which every UTXO claimed
  // `frozen: false`, because the chain has no idea what Officer froze. Sweeping was the worst of it: a
  // "send everything" emptied the frozen coin too.
  //
  // These run unlocked and reach the signer, which is why the fixture key had to be the published test
  // vector and nothing else.
  describe('frozen coins', () => {
    const fundedWorld = () => new Map<string, Funding>([[addressAt(0, 0), { confirmed: 50_000 }]]);
    /** The single outpoint the stub serves for a funded address. */
    const OUTPOINT = `${'a'.repeat(64)}:0`;
    const TO = 'bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu';

    test('automatic selection will not spend one', async () => {
      const { backend, stub } = backendFor(fundedWorld(), UNLOCKED_SIGNER);
      await expect(
        backend.sendCoins({ address: TO, amountSats: 10_000, satPerVbyte: 5, frozenOutpoints: [OUTPOINT] }),
      ).rejects.toThrow(/no spendable UTXOs/);
      // Nothing was signed and nothing left the machine.
      expect(stub.calls).not.toContain('broadcast');
    });

    test('sendAll will not sweep one', async () => {
      const { backend, stub } = backendFor(fundedWorld(), UNLOCKED_SIGNER);
      await expect(
        backend.sendCoins({ address: TO, sendAll: true, satPerVbyte: 5, frozenOutpoints: [OUTPOINT] }),
      ).rejects.toThrow(/no spendable UTXOs/);
      expect(stub.calls).not.toContain('broadcast');
    });

    test('the same send succeeds once the coin is not frozen', async () => {
      // The control. Without it the two tests above would still pass if sending were broken outright.
      const { backend, stub } = backendFor(fundedWorld(), UNLOCKED_SIGNER);
      const result = await backend.sendCoins({ address: TO, amountSats: 10_000, satPerVbyte: 5 });
      expect(result.txid).toBe('d'.repeat(64));
      expect(stub.calls).toContain('broadcast');
    });
  });

  test('fee estimates pass through untouched', async () => {
    const { backend } = backendFor(new Map());
    expect(await backend.estimateFees()).toEqual({
      fastestFee: 10,
      halfHourFee: 8,
      hourFee: 5,
      economyFee: 2,
      minimumFee: 1,
    });
  });
});
