// LNDHub — port of Zeus's backends/LndHub.ts (which `extends LND` and overrides ~10 methods).
//
// LNDHub is a CUSTODIAL account API, not a node. The server holds the keys; this backend is a thin
// client over a REST facade (BlueWallet/LndHub, LNbits' lndhub extension, Alby, lntxbot, …). There is
// no node identity, no channel view, no UTXO set and no on-chain spend — only a balance, invoices,
// payments and (on some deployments) a single deposit address.
//
// Differences from Zeus, all deliberate:
//
//  1. AUTH. Zeus logs in from SettingsStore (SettingsStore.ts:2090-2130), parks `access_token` in an
//     observable and then never refreshes it — an expired token surfaces as a "bad auth" error string
//     in the UI. Here the token is cached in memory, acquired lazily, and re-acquired transparently on
//     the first auth failure of any request, through a single-flight promise so N concurrent calls
//     produce one login rather than N.
//  2. ERRORS. LNDHub reports failure as **HTTP 200 with `{error, code, message}`** (Zeus checks for
//     this ad hoc in three different stores: InvoicesStore.ts:410, TransactionsStore.ts:795). httpJson
//     only maps non-2xx, so every response goes through `unwrap()` first. Error code 1 ("bad auth") is
//     re-raised as a 401 so it feeds the same re-auth path as a real 401.
//  3. lookupInvoice / getTransactions. Zeus *inherits* LND's `/v1/invoice/:r_hash` and
//     `/v1/transactions`, neither of which exists on an LNDHub server — those calls simply fail
//     upstream. lookupInvoice here scans `/getuserinvoices`; on-chain history is read out of the
//     `bitcoind_tx` entries that `/gettxs` interleaves with lightning payments.
//  4. decodeInvoice. Zeus decodes BOLT11 locally (Bolt11Utils). The sidecar has no bolt11 decoder, so
//     this uses the server's own `/decodeinvoice`, which returns LND's payreq shape.
//  5. The `lnurlAuth` signing modes (Alby vs BlueWallet key derivation) are not ported — LNURL-auth is
//     not part of the WalletBackend contract.
//
// UNITS. LNDHub is sloppy here and each field has to be taken on its own terms:
//   • /balance          → BTC.AvailableBalance, satoshis
//   • /getuserinvoices  → `amt`, satoshis
//   • /gettxs (payment) → `value` and `fee`, satoshis, where `value` already includes `fee`
//   • /gettxs (onchain) → `amount`, **BTC** as a float (verbatim from bitcoind's listtransactions)
//   • /decodeinvoice    → `num_satoshis` and `num_msat` side by side
// Everything is normalised to the contract's sats-as-number / msats-as-decimal-string rule on the way out.

import {
  BackendError,
  type AddressType,
  type Balances,
  type BackendKind,
  type Capability,
  type CreateInvoiceRequest,
  type DecodedInvoice,
  type Invoice,
  type InvoiceState,
  type NewAddressRequest,
  type NodeInfo,
  type OnchainTx,
  type PayInvoiceRequest,
  type Payment,
  type BitcoinNetwork,
} from '../types';
import { BaseBackend, base64ToHex, httpJson } from './base';
import { decodeBolt11 } from '../bolt11';

export type LndHubConfig = {
  /** Base URL of the LNDHub server, e.g. https://lndhub.example.com or https://ln.getalby.com/lndhub. */
  url: string;
  login: string;
  password: string;
  /** Opt-in TLS relaxation for a self-hosted server with a self-signed cert. Never default. */
  allowSelfSigned?: boolean;
};

// ── upstream wire shapes ─────────────────────────────────────────────────────────────────────────

/** Every LNDHub response can be this instead of the documented shape, with HTTP 200. */
type LndHubErrorBody = { error?: string | boolean | number; code?: number; message?: string };

/** node's `JSON.stringify(Buffer)` — LNDHub leaks raw Buffers for hash/preimage fields. */
type LndHubBuffer = { type?: string; data?: number[] };
type LndHubBytes = string | LndHubBuffer;

type LndHubAuth = { access_token?: string; refresh_token?: string };

type LndHubBalance = { BTC?: { AvailableBalance?: number; TotalBalance?: number } };

type LndHubGetInfo = {
  identity_pubkey?: string;
  alias?: string;
  version?: string;
  block_height?: number;
  testnet?: boolean;
  synced_to_chain?: boolean;
  chains?: { chain?: string; network?: string }[];
};

type LndHubUserInvoice = {
  payment_request?: string;
  pay_req?: string;
  r_hash?: LndHubBytes;
  payment_hash?: LndHubBytes;
  description?: string;
  memo?: string;
  ispaid?: boolean;
  /** satoshis */
  amt?: number | string;
  amt_paid_sat?: number | string;
  amt_paid_msat?: number | string;
  /** seconds of validity, relative to `timestamp` */
  expire_time?: number;
  /** unix seconds */
  timestamp?: number | string;
  settled_at?: number;
  type?: string;
};

/** `/gettxs` interleaves outgoing lightning payments and imported on-chain deposits. */
type LndHubTx = {
  type?: string;
  // paid_invoice
  payment_preimage?: LndHubBytes;
  payment_hash?: LndHubBytes;
  payment_request?: string;
  /** satoshis, and LNDHub folds `fee` into it (User.getTxs) */
  value?: number | string;
  /** satoshis */
  fee?: number | string;
  memo?: string;
  description?: string;
  timestamp?: number | string;
  // bitcoind_tx
  txid?: string;
  /** BTC, float, straight from bitcoind */
  amount?: number;
  confirmations?: number;
  address?: string;
  category?: string;
  time?: number;
  blockheight?: number;
};

type LndHubAddress = { address?: string };

type LndHubDecoded = {
  destination?: string;
  payment_hash?: string;
  num_satoshis?: string | number;
  num_msat?: string | number;
  timestamp?: string | number;
  expiry?: string | number;
  description?: string;
  cltv_expiry?: string | number;
  route_hints?: unknown[];
  features?: Record<string, { name?: string }>;
};

/** `/payinvoice` passes LND's sendPaymentSync response through, mostly. */
type LndHubPayResult = {
  payment_error?: string;
  payment_preimage?: LndHubBytes;
  payment_hash?: LndHubBytes;
  payment_route?: {
    total_amt?: number | string;
    total_fees?: number | string;
    total_amt_msat?: number | string;
    total_fees_msat?: number | string;
  };
  decoded?: LndHubDecoded;
  /** some forks answer a zero-amount pay with the amount they chose */
  num_satoshis?: number | string;
};

type LndHubCall = {
  path: string;
  method?: 'GET' | 'POST';
  body?: unknown;
  query?: Record<string, string | number | boolean | undefined>;
};

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

const HEX32 = /^[0-9a-f]{64}$/i;

/** LNDHub returns a 32-byte field as hex, base64, url-safe base64 or a stringified Buffer. */
function bytesToHex(value: LndHubBytes | undefined | null): string {
  if (!value) return '';
  if (typeof value === 'string') {
    if (HEX32.test(value)) return value.toLowerCase();
    return base64ToHex(value.replace(/-/g, '+').replace(/_/g, '/'));
  }
  if (Array.isArray(value.data)) return Buffer.from(value.data).toString('hex');
  return '';
}

const num = (value: number | string | undefined | null): number => {
  const n = typeof value === 'string' ? Number(value) : value;
  return typeof n === 'number' && Number.isFinite(n) ? n : 0;
};

const satsToMsat = (sats: number): string => (BigInt(Math.round(sats)) * 1000n).toString();

/** msat string → whole satoshis. LNDHub cannot express sub-satoshi amounts anywhere. */
function msatToSats(msat: string, field: string): number {
  const value = BigInt(msat);
  if (value % 1000n !== 0n) {
    throw new BackendError(`lndhub cannot express sub-satoshi amounts (${field}=${msat}msat)`, 400, 'BAD_AMOUNT');
  }
  return Number(value / 1000n);
}

/** LNDHub tells us nothing about the address it hands out, so classify it by prefix. */
function addressType(address: string): AddressType {
  const a = address.toLowerCase();
  if (a.startsWith('bc1p') || a.startsWith('tb1p') || a.startsWith('bcrt1p')) return 'p2tr';
  if (a.startsWith('bc1') || a.startsWith('tb1') || a.startsWith('bcrt1')) return 'p2wpkh';
  if (a.startsWith('3') || a.startsWith('2')) return 'p2sh-p2wpkh';
  return 'p2pkh';
}

// ── backend ──────────────────────────────────────────────────────────────────────────────────────

export class LndHubBackend extends BaseBackend {
  readonly kind: BackendKind = 'lndhub';

  // Custodial: receive and send over lightning, plus a deposit address on deployments that expose
  // /getbtc. No on-chain send, no coin control, no PSBT, no channels, no peers, no message signing —
  // the wallet does not hold the keys those would need.
  protected readonly capabilities: ReadonlySet<Capability> = new Set<Capability>([
    'lightningReceive',
    'lightningSend',
    'onchainReceive',
  ]);

  private accessToken: string | null = null;
  /** In-flight login, shared by every caller that needs a token — the single-flight refresh. */
  private pendingLogin: Promise<string> | null = null;

  constructor(private readonly config: LndHubConfig) {
    super();
  }

  // ── auth ───────────────────────────────────────────────────────────────────────────────────────

  /**
   * `POST /auth?type=auth {login, password}` → `{access_token, refresh_token}`. The refresh_token
   * grant (`type=refresh_token`) is deliberately not used: we hold the password, so a fresh login is
   * one round-trip either way and has one failure mode instead of two.
   */
  private async login(): Promise<string> {
    const res = await httpJson<LndHubAuth & LndHubErrorBody>({
      base: this.config.url,
      path: '/auth',
      method: 'POST',
      query: { type: 'auth' },
      body: { login: this.config.login, password: this.config.password },
      allowSelfSigned: this.config.allowSelfSigned,
    });
    if (res?.error || !res?.access_token) {
      const message = res?.message ?? (typeof res?.error === 'string' ? res.error : 'lndhub rejected the login');
      throw new BackendError(message, 401, 'LNDHUB_AUTH_FAILED');
    }
    this.accessToken = res.access_token;
    return res.access_token;
  }

  private async token(force = false): Promise<string> {
    if (!force && this.accessToken) return this.accessToken;
    // Concurrent 401s all await the same login; whoever loses the race gets the winner's token.
    this.pendingLogin ??= this.login().finally(() => {
      this.pendingLogin = null;
    });
    return this.pendingLogin;
  }

  /** LNDHub's 200-with-`{error}` channel, folded back into the exception path. */
  private unwrap<T>(payload: T): T {
    const body = payload as LndHubErrorBody | null;
    if (body && typeof body === 'object' && !Array.isArray(body) && body.error) {
      const message = body.message || (typeof body.error === 'string' ? body.error : 'lndhub returned an error');
      // code 1 is LNDHub's "bad auth"; some forks only put the phrase in the message.
      if (body.code === 1 || /bad auth/i.test(message)) {
        throw new BackendError(message, 401, 'LNDHUB_BAD_AUTH');
      }
      throw new BackendError(message, 502, body.code != null ? `LNDHUB_${body.code}` : undefined);
    }
    return payload;
  }

  private async call<T>(req: LndHubCall): Promise<T> {
    const send = async (token: string): Promise<T> =>
      this.unwrap(
        await httpJson<T>({
          base: this.config.url,
          path: req.path,
          method: req.method ?? 'GET',
          body: req.body,
          query: req.query,
          headers: { Authorization: `Bearer ${token}` },
          allowSelfSigned: this.config.allowSelfSigned,
        }),
      );

    try {
      return await send(await this.token());
    } catch (err) {
      // One retry, and only for auth — a second 401 with a token minted seconds ago is a real failure.
      if (!(err instanceof BackendError) || err.status !== 401) throw err;
      this.accessToken = null;
      return send(await this.token(true));
    }
  }

  // ── node / balances ────────────────────────────────────────────────────────────────────────────

  /**
   * Best-effort. `/getinfo` exists on BlueWallet-derived servers but not on every fork (Alby, LNbits),
   * so a failure degrades to an anonymous descriptor rather than breaking connect. Zeus sidesteps this
   * by reporting `supportsNodeInfo() = false` and never calling it.
   */
  override async getInfo(): Promise<NodeInfo> {
    let info: LndHubGetInfo | null = null;
    try {
      info = await this.call<LndHubGetInfo>({ path: '/getinfo' });
    } catch {
      info = null;
    }

    const chain = info?.chains?.[0]?.network;
    const network: BitcoinNetwork =
      chain === 'testnet' || chain === 'signet' || chain === 'regtest'
        ? chain
        : info?.testnet === true
          ? 'testnet'
          : 'bitcoin';

    return {
      kind: this.kind,
      // The account is custodial — the pubkey, if any, belongs to the operator's node, not to us.
      pubkey: info?.identity_pubkey ?? null,
      alias: info?.alias ?? null,
      version: info?.version ?? null,
      network,
      blockHeight: info?.block_height ?? null,
      synced: info?.synced_to_chain ?? true,
    };
  }

  override async getBalances(): Promise<Balances> {
    const res = await this.call<LndHubBalance>({ path: '/balance' });
    return {
      // A custodial account has no on-chain balance of its own; deposits land in the lightning balance.
      onchainConfirmed: 0,
      onchainFrozen: 0,
      onchainUnconfirmed: 0,
      lightningBalance: num(res?.BTC?.AvailableBalance),
      // Inbound liquidity is the operator's problem and is never reported.
      lightningInbound: null,
    };
  }

  // ── on-chain ───────────────────────────────────────────────────────────────────────────────────

  /**
   * `/gettxs` mixes `paid_invoice` (lightning, outgoing) with `bitcoind_tx` (on-chain deposits copied
   * out of bitcoind's listtransactions). Only the latter belong here.
   */
  override async getTransactions(opts?: { limit?: number }): Promise<OnchainTx[]> {
    const txs = await this.fetchTxs(opts?.limit);
    return txs
      .filter((tx) => tx.type === 'bitcoind_tx' || (!!tx.txid && tx.payment_preimage === undefined))
      .map((tx) => ({
        txid: tx.txid ?? '',
        // `amount` here is BTC as a float, not sats. Round after scaling — 0.1 BTC is not exact.
        amount: Math.round(num(tx.amount) * 1e8),
        feeSats: null,
        blockHeight: tx.blockheight ?? null,
        timestamp: tx.time ?? (tx.timestamp != null ? num(tx.timestamp) : null),
        confirmations: tx.confirmations ?? 0,
        label: null,
        destAddresses: tx.address ? [tx.address] : [],
        rawHex: null,
      }));
  }

  /**
   * `/getbtc` returns the account's single deposit address as a one-element array (Zeus reads
   * `data[0].address`, InvoicesStore.ts:620). It is static per account, so `peek` is meaningless and
   * the requested address type is ignored — the operator chose it. On the deployments that hand back an
   * empty array until an address has been allocated, `/newbtc` allocates one.
   */
  override async getNewAddress(_req?: NewAddressRequest): Promise<{ address: string; type: AddressType }> {
    let address = await this.fetchBtcAddress();
    if (!address) {
      await this.call<unknown>({ path: '/newbtc', method: 'POST', body: {} });
      address = await this.fetchBtcAddress();
    }
    if (!address) {
      throw new BackendError('lndhub server does not offer on-chain deposits', 501, 'NOT_SUPPORTED');
    }
    return { address, type: addressType(address) };
  }

  private async fetchBtcAddress(): Promise<string> {
    const res = await this.call<LndHubAddress[] | LndHubAddress>({ path: '/getbtc' });
    if (Array.isArray(res)) return res[0]?.address ?? '';
    return res?.address ?? '';
  }

  // ── lightning ──────────────────────────────────────────────────────────────────────────────────

  override async getInvoices(opts?: { limit?: number }): Promise<Invoice[]> {
    const res = await this.call<LndHubUserInvoice[]>({
      path: '/getuserinvoices',
      query: { limit: opts?.limit },
    });
    return (Array.isArray(res) ? res : []).map((inv) => this.toInvoice(inv));
  }

  override async createInvoice(req: CreateInvoiceRequest): Promise<Invoice> {
    if (req.preimage) return this.notSupported('custom preimages');
    if (req.isAmp) return this.notSupported('AMP invoices');
    // LNDHub answers a zero-amount invoice request with "Bad arguments" (Zeus special-cases the string
    // in InvoicesStore.ts:425); reject it here with something legible instead.
    if (!req.amountMsat || req.amountMsat === '0') {
      throw new BackendError('lndhub requires an invoice amount', 400, 'AMOUNT_REQUIRED');
    }
    const sats = msatToSats(req.amountMsat, 'amountMsat');
    // `expirySeconds` and `private` have no equivalent — the server picks both (Zeus:
    // supportsSettingInvoiceExpiration() = false).
    const res = await this.call<LndHubUserInvoice>({
      path: '/addinvoice',
      method: 'POST',
      body: { amt: String(sats), memo: req.memo ?? '' },
    });

    const bolt11 = res.payment_request ?? res.pay_req ?? '';
    let paymentHash = bytesToHex(res.r_hash ?? res.payment_hash);
    // A few forks omit r_hash on /addinvoice. The hash is already in the invoice we were just handed, so
    // read it locally rather than spending a second authenticated round-trip — and a second failure mode
    // — on `/decodeinvoice`, which those same forks are the least likely to implement.
    if (!paymentHash && bolt11) {
      paymentHash = decodeBolt11(bolt11).paymentHash;
    }

    const createdAt = res.timestamp != null ? num(res.timestamp) : Math.floor(Date.now() / 1000);
    return {
      paymentHash,
      bolt11,
      amountMsat: satsToMsat(sats),
      amountPaidMsat: null,
      memo: req.memo ?? res.description ?? null,
      state: 'open',
      createdAt,
      expiresAt: res.expire_time ? createdAt + res.expire_time : null,
      settledAt: null,
      preimage: null,
      isKeysend: false,
      isAmp: false,
    };
  }

  /**
   * LNDHub has no lookup endpoint — Zeus inherits LND's `/v1/invoice/:r_hash`, which 404s here. Scan
   * the invoice list instead. Bounded at 200 entries: an older invoice reports as not found rather
   * than paging the whole history on every poll.
   */
  override async lookupInvoice(paymentHash: string): Promise<Invoice | null> {
    const wanted = paymentHash.toLowerCase();
    const res = await this.call<LndHubUserInvoice[]>({ path: '/getuserinvoices', query: { limit: 200 } });
    const found = (Array.isArray(res) ? res : []).find((inv) => bytesToHex(inv.r_hash ?? inv.payment_hash) === wanted);
    return found ? this.toInvoice(found) : null;
  }

  /**
   * Zeus decodes BOLT11 client-side (Bolt11Utils) and never calls this endpoint. The sidecar has no
   * decoder, and the server's `/decodeinvoice?invoice=` answers with LND's payreq shape, which maps
   * onto DecodedInvoice one-for-one.
   */
  override async decodeInvoice(bolt11: string): Promise<DecodedInvoice> {
    const res = await this.call<LndHubDecoded>({ path: '/decodeinvoice', query: { invoice: bolt11 } });
    const msat = res.num_msat != null ? String(num(res.num_msat)) : null;
    return {
      bolt11,
      paymentHash: res.payment_hash ?? '',
      amountMsat: msat ?? (res.num_satoshis != null ? satsToMsat(num(res.num_satoshis)) : null),
      description: res.description ?? null,
      destination: res.destination ?? '',
      timestamp: num(res.timestamp),
      expiry: res.expiry != null ? num(res.expiry) : 3600,
      cltvExpiry: res.cltv_expiry != null ? num(res.cltv_expiry) : null,
      routeHints: Array.isArray(res.route_hints) && res.route_hints.length > 0,
      features: Object.values(res.features ?? {}).map((f) => f?.name ?? ''),
    };
  }

  override async getPayments(opts?: { limit?: number }): Promise<Payment[]> {
    const txs = await this.fetchTxs(opts?.limit);
    return txs
      .filter((tx) => tx.type === 'paid_invoice' || tx.payment_preimage !== undefined)
      .map((tx) => {
        // LNDHub's User.getTxs() sets `value = payment_route.total_amt + payment_route.total_fees`,
        // so the destination amount is `value - fee`. Both are satoshis.
        const feeSats = num(tx.fee);
        const amountSats = Math.max(num(tx.value) - feeSats, 0);
        return {
          paymentHash: bytesToHex(tx.payment_hash),
          preimage: bytesToHex(tx.payment_preimage) || null,
          amountMsat: satsToMsat(amountSats),
          feeMsat: satsToMsat(feeSats),
          // /gettxs only records payments that went through; failures are never persisted.
          status: 'succeeded' as const,
          createdAt: num(tx.timestamp ?? tx.time),
          destination: null,
          memo: tx.memo ?? tx.description ?? null,
          failureReason: null,
        };
      });
  }

  /**
   * `POST /payinvoice {invoice, amount}` — `amount` is satoshis and only consulted for a zero-amount
   * invoice. Fee limits and timeouts are the operator's policy (Zeus: supportsCustomFeeLimit() =
   * false), so feeLimitMsat / feeLimitPercent / timeoutSeconds are accepted and ignored.
   */
  override async payInvoice(req: PayInvoiceRequest): Promise<Payment> {
    const amountSats = req.amountMsat ? msatToSats(req.amountMsat, 'amountMsat') : undefined;
    const res = await this.call<LndHubPayResult>({
      path: '/payinvoice',
      method: 'POST',
      body: { invoice: req.bolt11, amount: amountSats },
    });

    const route = res.payment_route ?? {};
    const feeMsat =
      route.total_fees_msat != null ? String(num(route.total_fees_msat)) : satsToMsat(num(route.total_fees));
    // Prefer the invoice's own amount: `total_amt` is ambiguous across forks about whether fees are
    // included, whereas the decoded payreq is not.
    const decodedMsat =
      res.decoded?.num_msat != null
        ? String(num(res.decoded.num_msat))
        : res.decoded?.num_satoshis != null
          ? satsToMsat(num(res.decoded.num_satoshis))
          : null;
    const routedMsat =
      route.total_amt_msat != null ? String(num(route.total_amt_msat)) : satsToMsat(num(route.total_amt));

    const preimage = bytesToHex(res.payment_preimage) || null;
    // Hard failures arrive as `{error, message}` and have already thrown in unwrap(). `payment_error`
    // is LND's soft channel — a routing failure, reported as a failed Payment rather than an exception.
    const failed = !!res.payment_error;

    return {
      paymentHash: bytesToHex(res.payment_hash) || res.decoded?.payment_hash || '',
      preimage,
      amountMsat: decodedMsat ?? req.amountMsat ?? routedMsat,
      feeMsat,
      status: failed ? 'failed' : 'succeeded',
      createdAt: Math.floor(Date.now() / 1000),
      destination: res.decoded?.destination ?? null,
      memo: res.decoded?.description ?? null,
      failureReason: failed ? (res.payment_error ?? null) : null,
    };
  }

  // ── shared ─────────────────────────────────────────────────────────────────────────────────────

  private async fetchTxs(limit?: number): Promise<LndHubTx[]> {
    const res = await this.call<LndHubTx[]>({ path: '/gettxs', query: { limit } });
    return Array.isArray(res) ? res : [];
  }

  private toInvoice(inv: LndHubUserInvoice): Invoice {
    const createdAt = num(inv.timestamp);
    const expiresAt = inv.expire_time ? createdAt + inv.expire_time : null;
    const paid = inv.ispaid === true;
    // LNDHub has no cancel and no hold invoices, so 'canceled' and 'accepted' are unreachable; an
    // unpaid invoice past its expiry is reported as expired rather than left open.
    const state: InvoiceState = paid
      ? 'settled'
      : expiresAt != null && expiresAt < Math.floor(Date.now() / 1000)
        ? 'expired'
        : 'open';

    const amountMsat = inv.amt != null ? satsToMsat(num(inv.amt)) : null;
    const paidMsat =
      inv.amt_paid_msat != null
        ? String(num(inv.amt_paid_msat))
        : inv.amt_paid_sat != null
          ? satsToMsat(num(inv.amt_paid_sat))
          : paid
            ? amountMsat
            : null;

    return {
      paymentHash: bytesToHex(inv.r_hash ?? inv.payment_hash),
      bolt11: inv.payment_request ?? inv.pay_req ?? '',
      amountMsat,
      amountPaidMsat: paidMsat,
      memo: inv.description ?? inv.memo ?? null,
      state,
      createdAt,
      expiresAt,
      // The settle time is not recorded upstream; only the ispaid flag is.
      settledAt: inv.settled_at ?? null,
      // Preimages of received payments stay with the custodian.
      preimage: null,
      isKeysend: false,
      isAmp: false,
    };
  }
}
