// Coin selection and PSBT construction/signing for the native on-chain wallet backend.
//
// This is the raw-transaction half of the wallet: given a set of UTXOs discovered from the chain (see
// chain.ts) and a BIP32 root supplied by the caller, it picks inputs, builds a bitcoinjs-lib Psbt,
// signs it, and hands back a finalised transaction ready for `EsploraClient.broadcast`.
//
// It is modelled on Zeus's SweepStore (stores/SweepStore.ts) — the same p2pkh / p2sh-p2wpkh / p2wpkh /
// p2tr input construction, the same `toXOnly` from bitcoinjs-lib/src/psbt/bip371 — with three
// differences that matter:
//
//   1. Zeus sweeps a single WIF key and estimates the fee by signing a throwaway PSBT. Here the fee is
//      estimated analytically from per-script-type weights (see WEIGHTS below), because selection has
//      to know the fee before it knows the input set, and signing to find out costs a key.
//   2. Zeus refuses p2tr sweeps (ZEUS-3276). Taproot key-path spends work here: the private key is
//      BIP341-tweaked before signing and the signer exposes `signSchnorr`.
//   3. SECURITY: no function in this file reads a seed from disk, env or module state. `signAndFinalize`
//      takes the root HDKey as a parameter and the caller — keys.ts, via the backend's signer interface
//      — decides whether it is willing to hand one over. Broadcast deliberately lives in chain.ts so
//      the root is never held across an `await`.

import type { HDKey } from '@scure/bip32';
import * as bitcoin from 'bitcoinjs-lib';
import { toXOnly } from 'bitcoinjs-lib/src/psbt/bip371';
import * as ecc from '@bitcoinerlab/secp256k1';
import { BackendError, type AddressType, type BitcoinNetwork } from './types';

// bitcoinjs-lib needs an ECC backend for anything taproot (x-only point tweaking). Module-level and
// idempotent — `initEcc()` is exported so a consumer that only touches address derivation can force it
// without depending on import order.
let eccReady = false;

export function initEcc(): void {
  if (eccReady) return;
  bitcoin.initEccLib(ecc);
  eccReady = true;
}

initEcc();

// ── networks ─────────────────────────────────────────────────────────────────────────────────────

/**
 * bitcoinjs-lib ships bitcoin/testnet/regtest only. Signet shares testnet's address parameters exactly
 * (`tb` bech32 HRP, 0x6f p2pkh version, 0xc4 p2sh version) — only the genesis block and message magic
 * differ, and neither is used for address encoding or signing — so signet maps onto testnet.
 */
export function networkFor(network: BitcoinNetwork): bitcoin.Network {
  switch (network) {
    case 'bitcoin':
      return bitcoin.networks.bitcoin;
    case 'regtest':
      return bitcoin.networks.regtest;
    case 'testnet':
    case 'signet':
      return bitcoin.networks.testnet;
  }
}

/** BIP44 coin type: 0 for mainnet, 1 for every test chain (SLIP-44 "Testnet (all coins)"). */
export function coinTypeFor(network: BitcoinNetwork): 0 | 1 {
  return network === 'bitcoin' ? 0 : 1;
}

// ── script classification ────────────────────────────────────────────────────────────────────────

/**
 * Classify a scriptPubKey. Returns null for anything the wallet cannot own or size precisely (p2wsh,
 * bare multisig, op_return, future witness versions).
 *
 * Note that a bare p2sh script is reported as 'p2sh-p2wpkh'. On the *output* side that is exact — every
 * p2sh output is 23 bytes regardless of what redeems it — and on the input side the wallet only ever
 * owns the wrapped-segwit form, so the conflation is safe in both directions.
 */
export function scriptType(script: Uint8Array): AddressType | null {
  const b = script;
  if (b.length === 25 && b[0] === 0x76 && b[1] === 0xa9 && b[2] === 0x14 && b[23] === 0x88 && b[24] === 0xac) {
    return 'p2pkh';
  }
  if (b.length === 23 && b[0] === 0xa9 && b[1] === 0x14 && b[22] === 0x87) return 'p2sh-p2wpkh';
  if (b.length === 22 && b[0] === 0x00 && b[1] === 0x14) return 'p2wpkh';
  if (b.length === 34 && b[0] === 0x51 && b[1] === 0x20) return 'p2tr';
  return null;
}

/** Same, from hex. */
export function scriptTypeFromHex(hex: string): AddressType | null {
  return scriptType(Buffer.from(hex, 'hex'));
}

/** Classify a destination address by the script it encodes. Throws when the address is not valid here. */
export function addressScriptType(address: string, network: bitcoin.Network): AddressType | null {
  return scriptType(outputScriptFor(address, network));
}

/** `bitcoin.address.toOutputScript` with the failure turned into a 400 instead of a bare Error. */
export function outputScriptFor(address: string, network: bitcoin.Network): Buffer {
  try {
    return bitcoin.address.toOutputScript(address, network);
  } catch {
    throw new BackendError(`invalid address for this network: ${address}`, 400, 'INVALID_ADDRESS');
  }
}

// ── size and dust constants ──────────────────────────────────────────────────────────────────────

// Everything here is in *weight units* (4 wu = 1 vbyte) so segwit's quarter-vbyte discount survives the
// arithmetic instead of being rounded away per input. A flat 148-in/34-out estimate — the usual
// shortcut — overpays a p2wpkh spend by ~2.2x and a p2tr spend by ~2.6x, which at any real fee rate is
// money handed to a miner for nothing.
//
// Input weights, assuming a 72-byte low-R DER signature (71 is common, 72 is the worst case) and a
// 33-byte compressed pubkey:
//
//   p2pkh          32 txid + 4 vout + 1 len + 107 scriptSig + 4 seq = 148 vB    → 592 wu
//   p2sh-p2wpkh    64 base vB (scriptSig = 23-byte redeemScript push) + 108 wu witness → 364 wu
//   p2wpkh         41 base vB + 108 wu witness (2 items: 72-byte sig, 33-byte key) → 272 wu
//   p2tr key-path  41 base vB + 66 wu witness (1 item: 64-byte schnorr sig)        → 230 wu
//
// which come out at 148 / 91 / 68 / 57.5 vbytes — the same numbers Bitcoin Core assumes.
const INPUT_WEIGHT: Record<AddressType, number> = {
  p2pkh: 592,
  'p2sh-p2wpkh': 364,
  p2wpkh: 272,
  p2tr: 230,
};

// Output weights: 8-byte value + 1-byte script length + the script itself, all ×4.
//   p2pkh 25B script → 34 vB, p2sh 23B → 32 vB, p2wpkh 22B → 31 vB, p2tr 34B → 43 vB.
const OUTPUT_WEIGHT: Record<AddressType, number> = {
  p2pkh: 136,
  'p2sh-p2wpkh': 128,
  p2wpkh: 124,
  p2tr: 172,
};

/** Unclassifiable output (p2wsh, future witness versions): charge the 43-vbyte p2tr/p2wsh size. */
const OUTPUT_WEIGHT_UNKNOWN = 172;

/** version(4) + locktime(4), ×4. The input/output count varints are added separately. */
const TX_OVERHEAD_WEIGHT = 32;

/** Segwit marker + flag: 1 byte each, but they live in the witness so they weigh 1 wu each. */
const SEGWIT_MARKER_WEIGHT = 2;

/**
 * Bitcoin Core's dust threshold: 3 sat/vB (the default dustRelayFee) times the size of the output plus
 * the size of the input that would eventually spend it — witness inputs are counted as a flat 67 vB.
 *   p2pkh (34+148)*3 = 546 · p2sh (32+148)*3 = 540 · p2wpkh (31+67)*3 = 294 · p2tr (43+67)*3 = 330
 * A change output below its threshold is not created; the remainder is donated to the fee instead.
 */
export const DUST_THRESHOLD: Record<AddressType, number> = {
  p2pkh: 546,
  'p2sh-p2wpkh': 540,
  p2wpkh: 294,
  p2tr: 330,
};

/** Dust floor for an output we could not classify — use the most demanding value we know. */
const DUST_UNKNOWN = 546;

/** The network will not relay below this, so a caller asking for less gets bumped rather than stuck. */
const MIN_SAT_VB = 1;

function varintWeight(n: number): number {
  if (n < 0xfd) return 4;
  if (n <= 0xffff) return 12;
  return 20;
}

function isSegwit(type: AddressType): boolean {
  return type !== 'p2pkh';
}

export type VsizeParams = {
  inputs: AddressType[];
  /** Output script types, in order. `null` means "unclassifiable" and is charged 43 vbytes. */
  outputs: (AddressType | null)[];
};

/**
 * Virtual size of the transaction these inputs and outputs would produce, rounded up. Accurate to
 * within one vbyte per input (the DER signature is occasionally 71 bytes rather than 72), always in the
 * conservative direction, so the realised fee rate lands at or just above what was asked for.
 */
export function estimateVsize({ inputs, outputs }: VsizeParams): number {
  let weight = TX_OVERHEAD_WEIGHT + varintWeight(inputs.length) + varintWeight(outputs.length);
  if (inputs.some(isSegwit)) weight += SEGWIT_MARKER_WEIGHT;
  for (const type of inputs) weight += INPUT_WEIGHT[type];
  for (const type of outputs) weight += type === null ? OUTPUT_WEIGHT_UNKNOWN : OUTPUT_WEIGHT[type];
  return Math.ceil(weight / 4);
}

// ── coin selection ───────────────────────────────────────────────────────────────────────────────

/**
 * A UTXO the selector may spend. `derivationPath` is the FULL path from the wallet root
 * (`m/84'/0'/0'/0/7`), not the account-relative form the public Utxo type carries, because it is used
 * verbatim to derive the signing key.
 */
export type SpendableUtxo = {
  txid: string;
  vout: number;
  amountSats: number;
  address: string;
  addressType: AddressType;
  confirmations: number;
  derivationPath: string;
  frozen: boolean;
  /** scriptPubKey of the output being spent, hex. */
  scriptPubKeyHex: string;
  /** Compressed 33-byte pubkey of the owning address, hex. */
  pubkeyHex: string;
};

export type CoinSelectionParams = {
  utxos: SpendableUtxo[];
  /** Sats to pay the recipient. Ignored when `sendAll` is set. */
  targetSats: number;
  sendAll?: boolean;
  satPerVbyte: number;
  /** Script type of the recipient output; null when it is a script we cannot size exactly. */
  recipientType: AddressType | null;
  /** Script type the change output would use. */
  changeType: AddressType;
  /** Coin control: restrict the input set to these `txid:vout` outpoints. */
  outpoints?: string[];
  spendUnconfirmed?: boolean;
};

export type CoinSelection = {
  inputs: SpendableUtxo[];
  /** Sats actually paid to the recipient. Equals `targetSats` unless `sendAll`. */
  outputSats: number;
  /** Sats returned to the wallet, or null when no change output is created. */
  changeSats: number | null;
  feeSats: number;
  /** The vsize the fee was computed from. */
  vsize: number;
};

const sumSats = (utxos: SpendableUtxo[]): number => utxos.reduce((acc, u) => acc + u.amountSats, 0);

const outpointOf = (u: SpendableUtxo): string => `${u.txid}:${u.vout}`;

function dustFor(type: AddressType | null): number {
  return type === null ? DUST_UNKNOWN : DUST_THRESHOLD[type];
}

/**
 * Coin selection: a two-phase accumulative selector. Deliberately not branch-and-bound — BnB's payoff
 * is finding changeless solutions in a large UTXO set, and it needs a waste metric plus a fallback
 * anyway. Instead:
 *
 *   Phase 1 — smallest sufficient single input. Walk the eligible UTXOs smallest-first and take the
 *             first one that can cover the payment on its own. One input is the cheapest possible
 *             transaction, and going smallest-first quietly consolidates the wallet's dust over time.
 *   Phase 2 — largest-first accumulation. Nothing single-handedly covers it, so add UTXOs
 *             largest-first (confirmed before unconfirmed) until the total covers payment + fee. Going
 *             largest-first minimises the input count, and each input costs real money.
 *
 * Both phases decide change the same way: prefer a change output, but if what is left after the
 * with-change fee falls under the dust threshold, drop the change output and donate the remainder to
 * the fee. The donation is bounded by roughly (one output's fee + one dust threshold), so it can never
 * quietly become a large overpayment.
 */
export function selectCoins(params: CoinSelectionParams): CoinSelection {
  const rate = Math.max(params.satPerVbyte, MIN_SAT_VB);
  const allow = params.outpoints && params.outpoints.length > 0 ? new Set(params.outpoints) : null;

  const eligible = params.utxos.filter((u) => {
    // Frozen is never overridable, not even by naming the outpoint. Freezing is the owner saying "this
    // coin does not exist for spending purposes" — a reservation against an open channel, a coin held
    // for tax lots, dust being kept out of the way — and a rule that a sufficiently explicit request
    // can bypass is not a rule. The route refuses a named frozen outpoint before ever reaching here;
    // this is what makes that true for any future caller too.
    if (u.frozen) return false;
    // An explicit coin-control pick is otherwise authoritative: it overrides the confirmed-only
    // default, because the user named this exact outpoint and can see what it is.
    if (allow) return allow.has(outpointOf(u));
    if (!params.spendUnconfirmed && u.confirmations < 1) return false;
    return true;
  });

  if (eligible.length === 0) {
    const why = allow ? 'none of the selected outpoints are spendable' : 'no spendable UTXOs';
    throw new BackendError(why, 400, 'INSUFFICIENT_FUNDS');
  }

  if (params.sendAll) return sweepAll(eligible, rate, params.recipientType);

  if (!Number.isInteger(params.targetSats) || params.targetSats <= 0) {
    throw new BackendError('amountSats must be a positive integer', 400, 'INVALID_AMOUNT');
  }

  const attempt = (inputs: SpendableUtxo[]): CoinSelection | null => {
    const total = sumSats(inputs);
    const types = inputs.map((u) => u.addressType);
    const withChange = estimateVsize({ inputs: types, outputs: [params.recipientType, params.changeType] });
    const noChange = estimateVsize({ inputs: types, outputs: [params.recipientType] });
    const feeWithChange = Math.ceil(withChange * rate);
    const feeNoChange = Math.ceil(noChange * rate);

    const change = total - params.targetSats - feeWithChange;
    if (change >= DUST_THRESHOLD[params.changeType]) {
      return {
        inputs: [...inputs],
        outputSats: params.targetSats,
        changeSats: change,
        feeSats: feeWithChange,
        vsize: withChange,
      };
    }
    if (total >= params.targetSats + feeNoChange) {
      // Changeless: everything above the payment is fee. Smaller transaction, no dust output created.
      return {
        inputs: [...inputs],
        outputSats: params.targetSats,
        changeSats: null,
        feeSats: total - params.targetSats,
        vsize: noChange,
      };
    }
    return null;
  };

  // Phase 1 — smallest sufficient single input.
  const ascending = [...eligible].sort((a, b) => a.amountSats - b.amountSats);
  for (const utxo of ascending) {
    const single = attempt([utxo]);
    if (single) return single;
  }

  // Phase 2 — largest-first accumulation, confirmed coins ahead of unconfirmed ones.
  const ordered = [...eligible].sort((a, b) => {
    const aPending = a.confirmations > 0 ? 0 : 1;
    const bPending = b.confirmations > 0 ? 0 : 1;
    if (aPending !== bPending) return aPending - bPending;
    return b.amountSats - a.amountSats;
  });

  const chosen: SpendableUtxo[] = [];
  for (const utxo of ordered) {
    chosen.push(utxo);
    const selection = attempt(chosen);
    if (selection) return selection;
  }

  const available = sumSats(eligible);
  throw new BackendError(
    `insufficient funds: ${available} sat available, ${params.targetSats} sat requested plus fees`,
    400,
    'INSUFFICIENT_FUNDS',
  );
}

/** sendAll: every eligible coin in, one output out, the fee taken off that output. */
function sweepAll(inputs: SpendableUtxo[], rate: number, recipientType: AddressType | null): CoinSelection {
  const total = sumSats(inputs);
  const vsize = estimateVsize({ inputs: inputs.map((u) => u.addressType), outputs: [recipientType] });
  const feeSats = Math.ceil(vsize * rate);
  const outputSats = total - feeSats;
  if (outputSats < dustFor(recipientType)) {
    throw new BackendError(
      `sweep leaves ${outputSats} sat after a ${feeSats} sat fee, below the dust threshold`,
      400,
      'INSUFFICIENT_FUNDS',
    );
  }
  return { inputs: [...inputs], outputSats, changeSats: null, feeSats, vsize };
}

// ── PSBT construction ────────────────────────────────────────────────────────────────────────────

/** RBF-signalling sequence (BIP125): any input below 0xfffffffe marks the whole transaction replaceable. */
const SEQUENCE_RBF = 0xfffffffd;
const SEQUENCE_FINAL = 0xffffffff;

export type PsbtInputSource = {
  txid: string;
  vout: number;
  amountSats: number;
  addressType: AddressType;
  /** scriptPubKey being spent, hex. */
  scriptPubKeyHex: string;
  /** Compressed 33-byte pubkey of the owning address, hex. */
  pubkeyHex: string;
  /** Full BIP32 path from the wallet root — returned in `inputPaths` for `signAndFinalize`. */
  derivationPath: string;
  /** Whole previous transaction, hex. Required for p2pkh inputs, unused otherwise. */
  prevTxHex?: string;
};

export type PsbtOutputSpec = { address: string; amountSats: number };

export type BuildPsbtParams = {
  network: bitcoin.Network;
  inputs: PsbtInputSource[];
  outputs: PsbtOutputSpec[];
  /** Signal RBF. Default true. */
  rbf?: boolean;
  locktime?: number;
};

export type BuiltPsbt = {
  psbt: bitcoin.Psbt;
  /** Derivation paths in input order — pass straight to `signAndFinalize`. */
  inputPaths: string[];
};

/**
 * Build an unsigned PSBT. Input construction is per script type, exactly as Zeus's SweepStore does it:
 * legacy p2pkh needs the whole previous transaction (`nonWitnessUtxo`), segwit needs only the output
 * being spent (`witnessUtxo`), wrapped segwit additionally needs the p2wpkh `redeemScript`, and taproot
 * needs the x-only internal key so the signer can be matched against the output key.
 */
export function buildPsbt(params: BuildPsbtParams): BuiltPsbt {
  initEcc();
  const { network } = params;
  if (params.inputs.length === 0) throw new BackendError('cannot build a PSBT with no inputs', 400);
  if (params.outputs.length === 0) throw new BackendError('cannot build a PSBT with no outputs', 400);

  const psbt = new bitcoin.Psbt({ network });
  psbt.setVersion(2);
  psbt.setLocktime(params.locktime ?? 0);

  const sequence = params.rbf === false ? SEQUENCE_FINAL : SEQUENCE_RBF;
  const inputPaths: string[] = [];

  for (const source of params.inputs) {
    const pubkey = Buffer.from(source.pubkeyHex, 'hex');
    const script = Buffer.from(source.scriptPubKeyHex, 'hex');
    const base = { hash: source.txid, index: source.vout, sequence };

    switch (source.addressType) {
      case 'p2pkh': {
        if (!source.prevTxHex) {
          throw new BackendError(`p2pkh input ${source.txid}:${source.vout} needs the previous tx hex`, 500);
        }
        psbt.addInput({ ...base, nonWitnessUtxo: Buffer.from(source.prevTxHex, 'hex') });
        break;
      }
      case 'p2wpkh': {
        psbt.addInput({ ...base, witnessUtxo: { script, value: source.amountSats } });
        break;
      }
      case 'p2sh-p2wpkh': {
        const redeem = bitcoin.payments.p2wpkh({ pubkey, network });
        if (!redeem.output) throw new BackendError('failed to build the p2wpkh redeemScript', 500);
        psbt.addInput({
          ...base,
          witnessUtxo: { script, value: source.amountSats },
          redeemScript: redeem.output,
        });
        break;
      }
      case 'p2tr': {
        psbt.addInput({
          ...base,
          witnessUtxo: { script, value: source.amountSats },
          tapInternalKey: toXOnly(pubkey),
        });
        break;
      }
    }

    inputPaths.push(source.derivationPath);
  }

  for (const output of params.outputs) {
    // Validate against the configured network before adding — bitcoinjs would accept a foreign-network
    // address encoded for a chain with the same prefixes and silently burn the funds.
    outputScriptFor(output.address, network);
    psbt.addOutput({ address: output.address, value: output.amountSats });
  }

  return { psbt, inputPaths };
}

// ── signing ──────────────────────────────────────────────────────────────────────────────────────

export type SignedTx = {
  txid: string;
  rawHex: string;
  /** Realised vsize of the signed transaction — compare against the estimate to audit fee accuracy. */
  vsize: number;
  feeSats: number;
};

/**
 * BIP341 key-path tweak. The output key is `P + H_TapTweak(P) * G` where P is the x-only internal key,
 * so the private key has to be tweaked the same way before it can produce a matching schnorr signature.
 * If the internal point has odd Y the scalar is negated first, since x-only keys are always even-Y.
 */
function tweakPrivateKey(priv: Uint8Array, pubkey: Buffer): Uint8Array {
  const even = pubkey[0] === 0x02 ? priv : ecc.privateNegate(priv);
  const tweak = bitcoin.crypto.taggedHash('TapTweak', toXOnly(pubkey));
  const tweaked = ecc.privateAdd(even, tweak);
  if (!tweaked) throw new BackendError('taproot tweak produced an invalid key', 500);
  return tweaked;
}

function keyMaterial(node: HDKey): { priv: Uint8Array; pubkey: Buffer } {
  const priv = node.privateKey;
  const pub = node.publicKey;
  if (!priv || !pub) throw new BackendError('derived node has no private key — cannot sign', 500);
  return { priv, pubkey: Buffer.from(pub) };
}

/** ECDSA signer for p2pkh / p2sh-p2wpkh / p2wpkh inputs. */
function ecdsaSigner(node: HDKey): bitcoin.Signer {
  const { priv, pubkey } = keyMaterial(node);
  return {
    publicKey: pubkey,
    sign: (hash: Buffer) => Buffer.from(ecc.sign(hash, priv)),
  };
}

/**
 * Schnorr signer for a taproot key-path spend. `publicKey` must be the TWEAKED key: bitcoinjs matches
 * `toXOnly(signer.publicKey)` against the output key in the prevout (psbt.js getTaprootHashesForSig),
 * and the untweaked internal key would simply not match, failing with "Can not sign for input".
 */
function taprootSigner(node: HDKey): bitcoin.Signer {
  const { priv, pubkey } = keyMaterial(node);
  const tweakedPriv = tweakPrivateKey(priv, pubkey);
  const tweakedPub = ecc.pointFromScalar(tweakedPriv, true);
  if (!tweakedPub) throw new BackendError('taproot tweak produced an invalid point', 500);
  return {
    publicKey: Buffer.from(tweakedPub),
    sign: (hash: Buffer) => Buffer.from(ecc.sign(hash, tweakedPriv)),
    signSchnorr: (hash: Buffer) => Buffer.from(ecc.signSchnorr(hash, tweakedPriv)),
  };
}

/** @scure/bip32 only accepts the apostrophe form of a hardened index. */
function normalizePath(path: string): string {
  return path.replace(/[hH]/g, "'");
}

/**
 * Sign every input from keys derived off `root`, finalise, and extract the transaction.
 *
 * `root` is a PARAMETER and is never cached, stored or closed over past this call: the caller holds the
 * key material and this function borrows it for the duration of a synchronous signing pass. Nothing
 * here awaits, so the root cannot be pinned alive by a pending network call.
 */
export function signAndFinalize(psbt: bitcoin.Psbt, root: HDKey, inputPaths: string[]): SignedTx {
  initEcc();
  const count = psbt.data.inputs.length;
  if (inputPaths.length !== count) {
    throw new BackendError(`expected ${count} derivation paths, got ${inputPaths.length}`, 500);
  }

  for (let i = 0; i < count; i++) {
    const path = inputPaths[i];
    const input = psbt.data.inputs[i];
    if (path === undefined || input === undefined) {
      throw new BackendError(`missing derivation path for input #${i}`, 500);
    }
    const node = root.derive(normalizePath(path));
    // `tapInternalKey` is set only by the p2tr branch of buildPsbt, so it is the authoritative marker
    // for "this input needs a schnorr signature over a tweaked key".
    psbt.signInput(i, input.tapInternalKey ? taprootSigner(node) : ecdsaSigner(node));
  }

  psbt.finalizeAllInputs();
  const feeSats = psbt.getFee();
  const tx = psbt.extractTransaction();
  return { txid: tx.getId(), rawHex: tx.toHex(), vsize: tx.virtualSize(), feeSats };
}
