// BOLT11 invoice decoding, in-process — no node round-trip.
//
// Ported from Zeus's `utils/Bolt11Utils.ts`, which is itself derived from **light-bolt11-decoder**:
//   Copyright (c) 2021 bitcoinjs contributors, fiatjaf. MIT licence.
// The bech32 walk, the human-readable-part amount grammar and the signature preimage construction are
// that upstream's work and are preserved verbatim in behaviour.
//
// INTENTIONAL DIFFERENCES from Zeus's port:
//
//  1. ERRORS. Zeus mixes `throw new Error(...)` with silent `undefined` returns (a bad multiplier sets
//     `satoshis = null` and carries on; a `fromWordsUnsafe` failure yields `undefined`). Every failure
//     here is a `BackendError(msg, 400, 'BAD_INVOICE')`, so routes.ts answers 400 rather than leaking a
//     half-decoded invoice with null amounts into a payment flow.
//  2. NO bignumber.js. All amount arithmetic is BigInt: msat tops out at 2.1e18, which a double cannot
//     hold, and BigInt does the same job exactly with one less dependency.
//  3. NO @noble/hashes. The single sha256 comes from `node:crypto`, which Bun provides natively.
//  4. EAGER signature recovery. Zeus installs lazy getters for `destination`/`signature` because its
//     Activity list decodes hundreds of invoices to read only the timestamp. Here `DecodedInvoice`
//     requires a non-null `destination` on every call, so laziness would never pay off; the LRU cache
//     (which is what actually collapses repeat recoveries) is kept and sized for a server.
//  5. NO `sections` array. Zeus keeps light-bolt11-decoder's positional `{name, letters, value?: any}`
//     list for back-compat. Nothing here consumes it, and it was the source of the `any`. Every tag is
//     exposed as a named, typed field instead, with anything unrecognised in `unknownTags`.
//  6. MORE TAGS PARSED. Zeus leaves `r` (route hints), `9` (features) and `f` (fallback address) as
//     unknown tags. All three are parsed, because the contract's `routeHints` and `features` fields need
//     them. Feature bits are expanded to the indices of the set bits, matching how backends/clnrest.ts
//     renders CLN's bitmap.
//  7. SPEC-CONFORMANT LENIENCY/STRICTNESS. Known tags whose `data_length` is wrong are skipped and
//     unknown tags ignored (BOLT 11 requires both). An all-uppercase invoice — the QR form — is accepted
//     and lowercased; a MIXED-case one is rejected, as the spec requires and Zeus's blanket
//     `.toLowerCase()` did not. An explicit `n` payee is checked against the recovered key rather than
//     trusted blindly, which is the check Zeus skips.
//  8. NO SIMNET. Zeus carries the btcd-only `sb` prefix; `BitcoinNetwork` has no member for it.

import { createHash } from 'node:crypto';
import { bech32 } from 'bech32';
import { recoverPublicKey } from '@noble/secp256k1';
import { BackendError, type BitcoinNetwork, type DecodedInvoice } from './types';

// ── shape ────────────────────────────────────────────────────────────────────────────────────────

export type Bolt11NetworkParams = {
  /** The bech32 human-readable part that follows `ln`. */
  readonly bech32: string;
  readonly network: BitcoinNetwork;
  readonly pubKeyHash: number;
  readonly scriptHash: number;
  readonly validWitnessVersions: readonly number[];
};

/** One hop of an `r` field. Multiple `r` fields each describe a separate route. */
export type Bolt11RouteHop = {
  readonly pubkey: string;
  /** `block x tx x output`, the standard short_channel_id rendering. */
  readonly shortChannelId: string;
  readonly feeBaseMsat: number;
  readonly feeProportionalMillionths: number;
  readonly cltvExpiryDelta: number;
};

/** An `f` field. Left as witness-version + program rather than re-encoded to an address string. */
export type Bolt11Fallback = {
  /** 0-16 are segwit witness versions; 17 is P2PKH and 18 is P2SH. */
  readonly version: number;
  readonly programHex: string;
};

export type Bolt11UnknownTag = { readonly tagCode: number; readonly words: readonly number[] };

/** The full decode. `decodeBolt11` projects this onto the narrower `DecodedInvoice` contract. */
export type Bolt11Invoice = {
  /** The invoice, lowercased. */
  readonly paymentRequest: string;
  /** The whole human-readable part, e.g. `lnbc2500u`. */
  readonly prefix: string;
  readonly network: BitcoinNetwork;
  readonly networkParams: Bolt11NetworkParams;
  readonly timestamp: number;
  /** Decimal msat string, or null for a zero-amount ("any amount") invoice. Never a number. */
  readonly millisatoshis: string | null;
  /** Convenience only, and null whenever the amount is not a whole number of sats. */
  readonly satoshis: number | null;
  readonly paymentHash: string;
  readonly paymentSecret: string | null;
  readonly description: string | null;
  readonly descriptionHash: string | null;
  /** The explicit `n` payee field, when the invoice carries one. */
  readonly payee: string | null;
  /** `payee` when present, else recovered from the signature. 33-byte compressed pubkey, hex. */
  readonly destination: string;
  /** The raw `x` field; null when absent. `expirySeconds` applies the spec's 3600 default. */
  readonly expiry: number | null;
  readonly expirySeconds: number;
  readonly expiresAt: number;
  readonly cltvExpiry: number | null;
  readonly metadata: string | null;
  /** Indices of the set feature bits, as decimal strings. */
  readonly featureBits: readonly string[];
  readonly routes: readonly (readonly Bolt11RouteHop[])[];
  readonly fallbacks: readonly Bolt11Fallback[];
  /** 64-byte compact signature, hex. */
  readonly signature: string;
  readonly recoveryFlag: number;
  readonly unknownTags: readonly Bolt11UnknownTag[];
};

// ── constants ────────────────────────────────────────────────────────────────────────────────────

/**
 * Zeus caches 256 entries for a phone's Activity list. A server decodes the same invoice from several
 * call sites (list → detail → pay), so the cache earns its keep the same way; 1024 entries of a few
 * hundred bytes each is a rounding error against a Bun heap.
 */
const CACHE_LIMIT = 1024;
const cache = new Map<string, Bolt11Invoice>();

const MSAT_PER_BTC = 100_000_000_000n;
const MAX_MSAT = 2_100_000_000_000_000_000n;
const DIVISORS: Readonly<Record<string, bigint>> = {
  m: 1_000n,
  u: 1_000_000n,
  n: 1_000_000_000n,
  p: 1_000_000_000_000n,
};

const NETWORKS: readonly Bolt11NetworkParams[] = [
  { bech32: 'bc', network: 'bitcoin', pubKeyHash: 0x00, scriptHash: 0x05, validWitnessVersions: [0, 1] },
  { bech32: 'tb', network: 'testnet', pubKeyHash: 0x6f, scriptHash: 0xc4, validWitnessVersions: [0, 1] },
  { bech32: 'tbs', network: 'signet', pubKeyHash: 0x6f, scriptHash: 0xc4, validWitnessVersions: [0, 1] },
  { bech32: 'bcrt', network: 'regtest', pubKeyHash: 0x6f, scriptHash: 0xc4, validWitnessVersions: [0, 1] },
];

const TAG_NAMES = {
  1: 'payment_hash',
  3: 'route_hint',
  5: 'feature_bits',
  6: 'expiry',
  9: 'fallback_address',
  13: 'description',
  16: 'payment_secret',
  19: 'payee',
  23: 'description_hash',
  24: 'min_final_cltv_expiry',
  27: 'metadata',
} as const satisfies Record<number, string>;

/** BOLT 11: a reader MUST skip a `p`, `s`, `h` or `n` field whose data_length is not the fixed size. */
const FIXED_TAG_WORDS: Readonly<Record<number, number>> = { 1: 52, 16: 52, 23: 52, 19: 53 };

/** The signature occupies the last 104 words — 65 bytes: 64-byte compact sig + 1 recovery byte. */
const SIGNATURE_WORDS = 104;
const TIMESTAMP_WORDS = 7;

/** BOLT 11 default when no `x` field is present. */
const DEFAULT_EXPIRY_SECONDS = 3600;

const ROUTE_HOP_BYTES = 51;

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

function badInvoice(message: string): never {
  throw new BackendError(`invalid bolt11 invoice: ${message}`, 400, 'BAD_INVOICE');
}

/** Big-endian base-32. BigInt because a hostile tag can claim far more than 53 bits. */
function wordsToInt(words: readonly number[]): number {
  let value = 0n;
  for (const word of words) value = value * 32n + BigInt(word);
  if (value > BigInt(Number.MAX_SAFE_INTEGER)) badInvoice('a numeric field is out of range');
  return Number(value);
}

function wordsToBytes(words: readonly number[]): Uint8Array {
  const bytes = bech32.fromWordsUnsafe(words);
  if (bytes == null) badInvoice('a field is not a whole number of bytes');
  return Uint8Array.from(bytes);
}

const wordsToHex = (words: readonly number[]): string => Buffer.from(wordsToBytes(words)).toString('hex');

const wordsToUtf8 = (words: readonly number[]): string => Buffer.from(wordsToBytes(words)).toString('utf8');

/**
 * The `9` field is a big-endian bit vector whose LAST bit is feature 0, so a word's position from the
 * end fixes the base index. Rendered as decimal strings to match backends/clnrest.ts's `featureBits`.
 */
function wordsToFeatureBits(words: readonly number[]): string[] {
  const bits: string[] = [];
  for (let i = words.length - 1; i >= 0; i--) {
    const word = words[i] ?? 0;
    const base = (words.length - 1 - i) * 5;
    for (let bit = 0; bit < 5; bit++) {
      if (word & (1 << bit)) bits.push(String(base + bit));
    }
  }
  return bits.sort((a, b) => Number(a) - Number(b));
}

function wordsToRoute(words: readonly number[]): Bolt11RouteHop[] {
  const bytes = Buffer.from(wordsToBytes(words));
  if (bytes.length === 0 || bytes.length % ROUTE_HOP_BYTES !== 0) badInvoice('a route hint is malformed');

  const hops: Bolt11RouteHop[] = [];
  for (let offset = 0; offset < bytes.length; offset += ROUTE_HOP_BYTES) {
    const hop = bytes.subarray(offset, offset + ROUTE_HOP_BYTES);
    const scid = hop.subarray(33, 41);
    const block = scid.readUIntBE(0, 3);
    const tx = scid.readUIntBE(3, 3);
    const output = scid.readUInt16BE(6);
    hops.push({
      pubkey: hop.subarray(0, 33).toString('hex'),
      shortChannelId: `${block}x${tx}x${output}`,
      feeBaseMsat: hop.readUInt32BE(41),
      feeProportionalMillionths: hop.readUInt32BE(45),
      cltvExpiryDelta: hop.readUInt16BE(49),
    });
  }
  return hops;
}

function wordsToFallback(words: readonly number[]): Bolt11Fallback | null {
  const version = words[0];
  // A fallback with no program is meaningless; the spec says to ignore an unparseable one.
  if (version === undefined || words.length < 2) return null;
  return { version, programHex: wordsToHex(words.slice(1)) };
}

/**
 * The amount grammar from the human-readable part: `<digits><multiplier?>`, where the multiplier
 * divides one bitcoin. Integer arithmetic throughout — a `p` amount that is not a multiple of 10 would
 * be sub-millisatoshi and is invalid rather than rounded.
 */
function hrpToMsat(amount: string, multiplier: string): string {
  if (!/^\d+$/.test(amount)) badInvoice(`"${amount}" is not a valid amount`);
  if (multiplier && !(multiplier in DIVISORS)) badInvoice(`"${multiplier}" is not a valid amount multiplier`);

  const value = BigInt(amount);
  const divisor = multiplier ? DIVISORS[multiplier] : undefined;
  const msat = divisor === undefined ? value * MSAT_PER_BTC : (value * MSAT_PER_BTC) / divisor;

  if (multiplier === 'p' && value % 10n !== 0n) badInvoice('amount has sub-millisatoshi precision');
  if (msat > MAX_MSAT) badInvoice('amount is outside of valid range');
  return msat.toString();
}

/** 5-bit → 8-bit, right-padded with zeros: the preimage the signature covers. */
function convertBits(words: readonly number[]): Uint8Array {
  let value = 0;
  let bits = 0;
  const result: number[] = [];
  for (const word of words) {
    value = (value << 5) | word;
    bits += 5;
    while (bits >= 8) {
      bits -= 8;
      result.push((value >> bits) & 0xff);
    }
  }
  if (bits > 0) result.push((value << (8 - bits)) & 0xff);
  return Uint8Array.from(result);
}

type Recovered = { destination: string; signature: string; recoveryFlag: number };

/** The signed message is sha256( utf8(prefix) || convertBits(dataWords) ). SEC1 recovery from there. */
function recoverPayee(prefix: string, sigWords: readonly number[], signedWords: readonly number[]): Recovered {
  const sigBytes = wordsToBytes(sigWords);
  const recoveryFlag = sigBytes[64];
  if (sigBytes.length !== 65 || recoveryFlag === undefined || recoveryFlag > 3) {
    badInvoice('signature is malformed');
  }

  const preimage = Buffer.concat([Buffer.from(prefix, 'utf8'), Buffer.from(convertBits(signedWords))]);
  const hash = createHash('sha256').update(preimage).digest();

  // @noble/secp256k1 v3 wants the recovery byte FIRST; bech32 carries it last.
  const recoverable = Buffer.concat([Buffer.from([recoveryFlag]), Buffer.from(sigBytes.subarray(0, 64))]);

  let pubkey: Uint8Array;
  try {
    pubkey = recoverPublicKey(recoverable, hash, { prehash: false });
  } catch {
    badInvoice('signature is not recoverable');
  }

  return {
    destination: Buffer.from(pubkey).toString('hex'),
    signature: Buffer.from(sigBytes.subarray(0, 64)).toString('hex'),
    recoveryFlag,
  };
}

/** `lnbc2500u` → the network params and the amount. */
function parsePrefix(prefix: string): { params: Bolt11NetworkParams; millisatoshis: string | null } {
  // Non-greedy on the hrp so the trailing digits+multiplier win: `lnbcrt500u` → bcrt / 500 / u. A prefix
  // with no amount ends in a letter the first pattern would eat, hence the amount-less second attempt.
  let matches = /^ln(\S+?)(\d+)([a-z]?)$/.exec(prefix);
  let amount = matches?.[2] ?? '';
  let multiplier = matches?.[3] ?? '';
  if (!matches) {
    matches = /^ln(\S+)$/.exec(prefix);
    amount = '';
    multiplier = '';
  }

  const hrp = matches?.[1];
  if (!hrp) badInvoice('not a lightning payment request');

  const params = NETWORKS.find((candidate) => candidate.bech32 === hrp);
  if (!params) badInvoice(`unknown network prefix "ln${hrp}"`);

  return { params, millisatoshis: amount ? hrpToMsat(amount, multiplier) : null };
}

type Tags = {
  paymentHash: string | null;
  paymentSecret: string | null;
  description: string | null;
  descriptionHash: string | null;
  payee: string | null;
  expiry: number | null;
  cltvExpiry: number | null;
  metadata: string | null;
  featureBits: string[];
  routes: Bolt11RouteHop[][];
  fallbacks: Bolt11Fallback[];
  unknownTags: Bolt11UnknownTag[];
};

/** Walks the tagged fields. First occurrence of a field wins; unknown and wrong-length fields are skipped. */
function parseTags(dataWords: readonly number[]): Tags {
  const tags: Tags = {
    paymentHash: null,
    paymentSecret: null,
    description: null,
    descriptionHash: null,
    payee: null,
    expiry: null,
    cltvExpiry: null,
    metadata: null,
    featureBits: [],
    routes: [],
    fallbacks: [],
    unknownTags: [],
  };

  let words = dataWords;
  while (words.length > 0) {
    const tagCode = words[0];
    if (tagCode === undefined) break;
    if (words.length < 3) badInvoice('a tagged field is truncated');

    const length = wordsToInt(words.slice(1, 3));
    words = words.slice(3);
    if (length > words.length) badInvoice('a tagged field overruns the invoice');
    const tagWords = words.slice(0, length);
    words = words.slice(length);

    const expected = FIXED_TAG_WORDS[tagCode];
    if (expected !== undefined && length !== expected) continue;

    const name: string | undefined = TAG_NAMES[tagCode as keyof typeof TAG_NAMES];
    switch (name) {
      case 'payment_hash':
        tags.paymentHash ??= wordsToHex(tagWords);
        break;
      case 'payment_secret':
        tags.paymentSecret ??= wordsToHex(tagWords);
        break;
      case 'description':
        tags.description ??= wordsToUtf8(tagWords);
        break;
      case 'description_hash':
        tags.descriptionHash ??= wordsToHex(tagWords);
        break;
      case 'payee':
        tags.payee ??= wordsToHex(tagWords);
        break;
      case 'expiry':
        tags.expiry ??= wordsToInt(tagWords);
        break;
      case 'min_final_cltv_expiry':
        tags.cltvExpiry ??= wordsToInt(tagWords);
        break;
      case 'metadata':
        tags.metadata ??= wordsToHex(tagWords);
        break;
      case 'feature_bits':
        if (tags.featureBits.length === 0) tags.featureBits = wordsToFeatureBits(tagWords);
        break;
      case 'route_hint':
        tags.routes.push(wordsToRoute(tagWords));
        break;
      case 'fallback_address': {
        const fallback = wordsToFallback(tagWords);
        if (fallback) tags.fallbacks.push(fallback);
        break;
      }
      default:
        tags.unknownTags.push({ tagCode, words: tagWords });
    }
  }

  return tags;
}

// ── the decoder ──────────────────────────────────────────────────────────────────────────────────

/**
 * Full decode, cached. The returned object is shared with every other caller for the same invoice and is
 * typed readonly throughout — treat it as frozen.
 */
export function decodeBolt11Invoice(paymentRequest: string): Bolt11Invoice {
  if (typeof paymentRequest !== 'string') badInvoice('expected a string');

  const trimmed = paymentRequest.trim();
  // The QR form is all-uppercase and legal; a mixed-case string is not (BOLT 11 / BIP-173).
  if (/[a-z]/.test(trimmed) && /[A-Z]/.test(trimmed)) badInvoice('mixed-case payment request');
  const normalized = trimmed.toLowerCase();

  const cached = cache.get(normalized);
  if (cached) {
    // Re-insert to move to the MRU end of the Map's insertion order.
    cache.delete(normalized);
    cache.set(normalized, cached);
    return cached;
  }

  if (!normalized.startsWith('ln')) badInvoice('not a lightning payment request');

  let decoded: { prefix: string; words: number[] };
  try {
    decoded = bech32.decode(normalized, Number.MAX_SAFE_INTEGER);
  } catch (err) {
    badInvoice(err instanceof Error ? err.message.toLowerCase() : 'bech32 decode failed');
  }

  if (decoded.words.length < SIGNATURE_WORDS + TIMESTAMP_WORDS) badInvoice('payment request is too short');

  const { params, millisatoshis } = parsePrefix(decoded.prefix);

  const signedWords = decoded.words.slice(0, -SIGNATURE_WORDS);
  const sigWords = decoded.words.slice(-SIGNATURE_WORDS);
  const timestamp = wordsToInt(signedWords.slice(0, TIMESTAMP_WORDS));
  const tags = parseTags(signedWords.slice(TIMESTAMP_WORDS));

  if (!tags.paymentHash) badInvoice('no payment hash');

  const recovered = recoverPayee(decoded.prefix, sigWords, signedWords);
  // BOLT 11: when an `n` field is present a reader MUST still check the signature against it. A correct
  // signer always recovers to its own key, so a mismatch means the invoice was tampered with.
  if (tags.payee && tags.payee !== recovered.destination) badInvoice('signature does not match the payee');

  const msat = millisatoshis === null ? null : BigInt(millisatoshis);
  const expirySeconds = tags.expiry ?? DEFAULT_EXPIRY_SECONDS;

  const invoice: Bolt11Invoice = {
    paymentRequest: normalized,
    prefix: decoded.prefix,
    network: params.network,
    networkParams: params,
    timestamp,
    millisatoshis,
    satoshis: msat !== null && msat % 1000n === 0n ? Number(msat / 1000n) : null,
    paymentHash: tags.paymentHash,
    paymentSecret: tags.paymentSecret,
    description: tags.description,
    descriptionHash: tags.descriptionHash,
    payee: tags.payee,
    destination: tags.payee ?? recovered.destination,
    expiry: tags.expiry,
    expirySeconds,
    expiresAt: timestamp + expirySeconds,
    cltvExpiry: tags.cltvExpiry,
    metadata: tags.metadata,
    featureBits: tags.featureBits,
    routes: tags.routes,
    fallbacks: tags.fallbacks,
    signature: recovered.signature,
    recoveryFlag: recovered.recoveryFlag,
    unknownTags: tags.unknownTags,
  };

  if (cache.size >= CACHE_LIMIT) {
    const oldest = cache.keys().next();
    if (!oldest.done) cache.delete(oldest.value);
  }
  cache.set(normalized, invoice);

  return invoice;
}

/**
 * The contract surface: a BOLT11 payment request as the backends report it. Throws
 * `BackendError(…, 400, 'BAD_INVOICE')` for anything that does not decode.
 */
export function decodeBolt11(bolt11: string): DecodedInvoice {
  const invoice = decodeBolt11Invoice(bolt11);
  return {
    bolt11: invoice.paymentRequest,
    paymentHash: invoice.paymentHash,
    amountMsat: invoice.millisatoshis,
    description: invoice.description,
    destination: invoice.destination,
    timestamp: invoice.timestamp,
    expiry: invoice.expirySeconds,
    cltvExpiry: invoice.cltvExpiry,
    routeHints: invoice.routes.length > 0,
    features: [...invoice.featureBits],
  };
}

/** Test seam: the LRU is process-global, so a test that measures recovery cost needs to reset it. */
export function clearBolt11Cache(): void {
  cache.clear();
}
