// Nostr Wallet Connect (NIP-47) — port of Zeus's backends/NostrWalletConnect.ts.
//
// A NWC connection is a capability grant, not a node: a `nostr+walletconnect://` URI carries a wallet
// service pubkey, one or more relays and a shared secret. Every operation is an encrypted nostr event
// round-tripped through a relay, so the whole surface is lightning-only and the wallet decides which
// commands it will honour.
//
// CAPABILITIES ARE DYNAMIC. This is the one backend whose `capabilities` set is not a constant: the
// wallet advertises its methods in the `get_info` response (falling back to the kind-13194 info event),
// and the set is rebuilt from that list on connect. Zeus hardcodes `supportsKeysend() = false` even
// though plenty of NWC wallets do keysend; here `keysend` appears iff the wallet lists `pay_keysend`.
// Before the first connect the set holds the NIP-47 baseline (make_invoice + pay_invoice), because
// `supports()` is synchronous and cannot await the handshake.
//
// Differences from Zeus, all deliberate:
//
//  1. TRANSPORT. Zeus drives `NostrWebLNProvider` from `@getalby/sdk`, a WebLN shim that silently
//     converts sats↔msats. This uses `NWCClient` from the same package (v8), which speaks raw NIP-47
//     — every amount in and out is millisatoshis, matching this contract's msat-as-string rule with
//     no lossy hop through sats. Bun/Node 22 both provide the global WebSocket that nostr-tools needs.
//  2. lookupInvoice. Zeus passes `Base64Utils.hexToBase64(r_hash)` (NostrWalletConnect.ts:78) — NIP-47
//     specifies payment_hash as **hex**, so that call cannot match. Hex is sent here.
//  3. INVOICE/PAYMENT SPLIT. Zeus calls `list_transactions` and filters `type` client-side, twice.
//     NIP-47 takes `type` as a request parameter; the filter is pushed to the wallet, with the
//     client-side filter kept as a guard for wallets that ignore it.
//  4. payInvoice. NIP-47 answers a payment with `{preimage, fees_paid}` and nothing else, so Zeus
//     returns a Payment with no hash. The payment hash is sha256(preimage) by definition, so it is
//     computed locally; the amount is filled from a best-effort `lookup_invoice` when the wallet
//     supports it, otherwise from the request.
//  5. decodeInvoice is LOCAL. NIP-47 has no decode command, so this is the one operation that never
//     touches the relay: `../bolt11` (ported from Zeus's Bolt11Utils) decodes in-process. On-chain,
//     channels, peers and message signing do stay at BaseBackend's 501 — NIP-47 `sign_message` signs
//     with the node key, which is not what the contract's signMessage means.

import { createHash } from 'node:crypto';
import { Nip47Error, Nip47TimeoutError, NWCClient, type Nip47Method, type Nip47Transaction } from '@getalby/sdk/nwc';
import { decodeBolt11 } from '../bolt11';
import {
  BackendError,
  type Balances,
  type BackendKind,
  type BitcoinNetwork,
  type Capability,
  type CreateInvoiceRequest,
  type DecodedInvoice,
  type Invoice,
  type InvoiceState,
  type KeysendRequest,
  type NodeInfo,
  type PayInvoiceRequest,
  type Payment,
  type PaymentStatus,
} from '../types';
import { BaseBackend } from './base';

export type NwcConfig = {
  /** A `nostr+walletconnect://<wallet-pubkey>?relay=…&secret=…` URI. */
  connectionUri: string;
};

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

/**
 * The SDK types `state` as required, but it was added late to NIP-47 and pre-1.0 wallets omit it —
 * their only settlement signal is a non-zero `settled_at`. Amounts are millisatoshis throughout.
 */
type NwcTransaction = Omit<Nip47Transaction, 'state'> & { state?: Nip47Transaction['state'] };

/** NIP-47 error codes, mapped onto HTTP so routes.ts can answer honestly. */
const ERROR_STATUS: Record<string, number> = {
  NOT_IMPLEMENTED: 501,
  UNSUPPORTED_ENCRYPTION: 501,
  UNAUTHORIZED: 403,
  RESTRICTED: 403,
  INSUFFICIENT_BALANCE: 402,
  QUOTA_EXCEEDED: 402,
  PAYMENT_FAILED: 502,
  NOT_FOUND: 404,
  RATE_LIMITED: 429,
  INTERNAL: 502,
  OTHER: 502,
};

/** The keysend TLV that carries a human-readable message (the de-facto standard record). */
const KEYSEND_MESSAGE_TLV = 34349334;

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

const now = (): number => Math.floor(Date.now() / 1000);

/** payment_hash = sha256(preimage) — how NIP-47 lets us recover a hash it never sends back. */
function hashFromPreimage(preimage: string | undefined): string {
  if (!preimage) return '';
  return createHash('sha256').update(Buffer.from(preimage, 'hex')).digest('hex');
}

/** msat decimal string → the integer msats NIP-47 wants, with the JS-safe range enforced. */
function toMsatNumber(msat: string, field: string): number {
  const value = BigInt(msat);
  if (value < 0n || value > BigInt(Number.MAX_SAFE_INTEGER)) {
    throw new BackendError(`${field}=${msat}msat is out of range for NIP-47`, 400, 'BAD_AMOUNT');
  }
  return Number(value);
}

function toBackendError(err: unknown, op: string): BackendError {
  if (err instanceof BackendError) return err;
  if (err instanceof Nip47TimeoutError) {
    return new BackendError(`nwc ${op} timed out: ${err.message}`, 504, err.code);
  }
  if (err instanceof Nip47Error) {
    return new BackendError(`nwc ${op} failed: ${err.message}`, ERROR_STATUS[err.code] ?? 502, err.code);
  }
  return new BackendError(`nwc ${op} failed: ${String(err)}`, 502, 'NWC_ERROR');
}

function toInvoiceState(tx: NwcTransaction): InvoiceState {
  if (tx.state === 'settled') return 'settled';
  if (tx.state === 'accepted') return 'accepted';
  // NIP-47 has no explicit cancel; a hold invoice that was released comes back as failed.
  if (tx.state === 'failed') return 'canceled';
  if (tx.settled_at) return 'settled';
  if (tx.expires_at && tx.expires_at < now()) return 'expired';
  return 'open';
}

function toPaymentStatus(tx: NwcTransaction): PaymentStatus {
  if (tx.state === 'settled') return 'succeeded';
  if (tx.state === 'failed') return 'failed';
  if (tx.state) return 'pending';
  return tx.settled_at ? 'succeeded' : 'pending';
}

function toInvoice(tx: NwcTransaction): Invoice {
  const amountMsat = tx.amount != null ? String(tx.amount) : null;
  const state = toInvoiceState(tx);
  return {
    paymentHash: tx.payment_hash ?? '',
    bolt11: tx.invoice ?? '',
    amountMsat,
    amountPaidMsat: state === 'settled' ? amountMsat : null,
    memo: tx.description || null,
    state,
    createdAt: tx.created_at ?? now(),
    expiresAt: tx.expires_at || null,
    settledAt: tx.settled_at || null,
    preimage: tx.preimage || null,
    // An incoming payment with no BOLT11 attached to it can only have been a keysend.
    isKeysend: !tx.invoice && !!tx.payment_hash,
    // NIP-47 has no AMP concept.
    isAmp: false,
  };
}

function toPayment(tx: NwcTransaction): Payment {
  return {
    paymentHash: tx.payment_hash ?? '',
    preimage: tx.preimage || null,
    amountMsat: String(tx.amount ?? 0),
    feeMsat: String(tx.fees_paid ?? 0),
    status: toPaymentStatus(tx),
    createdAt: tx.created_at ?? now(),
    // The wallet never names the payee; only the invoice it paid.
    destination: null,
    memo: tx.description || null,
    failureReason: null,
  };
}

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

export class NwcBackend extends BaseBackend {
  readonly kind: BackendKind = 'nwc';

  /**
   * Mutable behind a ReadonlySet view — `capabilities` and `caps` are the same object, so rebuilding
   * the set after the get_info handshake is visible through `supports()` without reassigning a
   * readonly field. Seeded with the NIP-47 baseline until the wallet says otherwise.
   */
  private readonly caps = new Set<Capability>(['lightningReceive', 'lightningSend']);
  protected readonly capabilities: ReadonlySet<Capability> = this.caps;

  private client: NWCClient | null = null;
  private connecting: Promise<NWCClient> | null = null;
  private methods: ReadonlySet<Nip47Method> = new Set<Nip47Method>();

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

  // ── connection ─────────────────────────────────────────────────────────────────────────────────

  /** Single-flight connect: the relay subscription and the get_info handshake happen exactly once. */
  private async connect(): Promise<NWCClient> {
    if (this.client) return this.client;
    this.connecting ??= this.open().finally(() => {
      this.connecting = null;
    });
    return this.connecting;
  }

  private async open(): Promise<NWCClient> {
    let client: NWCClient;
    try {
      client = new NWCClient({ nostrWalletConnectUrl: this.config.connectionUri });
    } catch (err) {
      throw new BackendError(`invalid NWC connection URI: ${String(err)}`, 400, 'BAD_CONFIG');
    }

    try {
      const info = await client.getInfo();
      this.applyMethods(info.methods);
    } catch (err) {
      // Wallets that do not implement get_info still publish a kind-13194 info event listing their
      // capabilities. If neither is reachable the connection itself is broken — surface that.
      try {
        const service = await client.getWalletServiceInfo();
        this.applyMethods(service.capabilities.filter((cap): cap is Nip47Method => cap !== 'notifications'));
      } catch {
        client.close();
        throw toBackendError(err, 'connect');
      }
    }

    this.client = client;
    return client;
  }

  /** Capability negotiation: the whole point of NWC's get_info. */
  private applyMethods(methods: Nip47Method[] | undefined): void {
    if (!methods?.length) return;
    this.methods = new Set(methods);
    this.caps.clear();
    if (this.methods.has('make_invoice')) this.caps.add('lightningReceive');
    if (this.methods.has('pay_invoice')) this.caps.add('lightningSend');
    if (this.methods.has('pay_keysend')) this.caps.add('keysend');
  }

  private async run<T>(op: string, fn: (client: NWCClient) => Promise<T>): Promise<T> {
    const client = await this.connect();
    try {
      return await fn(client);
    } catch (err) {
      throw toBackendError(err, op);
    }
  }

  /** Drop the relay subscription. Not part of WalletBackend; the factory calls it on teardown. */
  close(): void {
    this.client?.close();
    this.client = null;
    this.methods = new Set<Nip47Method>();
  }

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

  override async getInfo(): Promise<NodeInfo> {
    const info = await this.run('get_info', (client) => client.getInfo());
    const network = info.network === 'mainnet' || !info.network ? 'bitcoin' : info.network;
    return {
      kind: this.kind,
      // The pubkey belongs to the wallet service's node, not to this connection.
      pubkey: info.pubkey || null,
      alias: info.alias || null,
      // NIP-47 carries no implementation version.
      version: null,
      network: (['bitcoin', 'testnet', 'signet', 'regtest'] as const).includes(network as BitcoinNetwork)
        ? (network as BitcoinNetwork)
        : 'bitcoin',
      blockHeight: info.block_height ?? null,
      // A wallet that answers at all is by definition usable; there is no sync flag in NIP-47.
      synced: true,
    };
  }

  override async getBalances(): Promise<Balances> {
    const res = await this.run('get_balance', (client) => client.getBalance());
    return {
      // NWC is lightning-only; there is no on-chain side to report.
      onchainConfirmed: 0,
      onchainFrozen: 0,
      onchainUnconfirmed: 0,
      // `balance` is msats (the WebLN shim Zeus uses divides by 1000 before the UI ever sees it).
      lightningBalance: Math.floor((res.balance ?? 0) / 1000),
      lightningInbound: null,
    };
  }

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

  override async getInvoices(opts?: { limit?: number }): Promise<Invoice[]> {
    const res = await this.run('list_transactions', (client) =>
      client.listTransactions({ type: 'incoming', limit: opts?.limit, unpaid: true }),
    );
    // The `type` filter is a request parameter, but older wallets ignore it — filter again.
    return (res.transactions ?? []).filter((tx) => tx.type !== 'outgoing').map(toInvoice);
  }

  override async createInvoice(req: CreateInvoiceRequest): Promise<Invoice> {
    if (!this.caps.has('lightningReceive')) return this.notSupported('lightning receive');
    // No NIP-47 wallet accepts a caller-supplied preimage or an AMP invoice, and `private` has no
    // equivalent — the wallet picks its own route hints.
    if (req.preimage) return this.notSupported('custom preimages');
    if (req.isAmp) return this.notSupported('AMP invoices');
    if (!req.amountMsat || req.amountMsat === '0') {
      throw new BackendError('nwc requires an invoice amount', 400, 'AMOUNT_REQUIRED');
    }
    const amount = toMsatNumber(req.amountMsat, 'amountMsat');

    const tx = await this.run('make_invoice', (client) =>
      client.makeInvoice({ amount, description: req.memo, expiry: req.expirySeconds }),
    );
    return toInvoice(tx);
  }

  override async lookupInvoice(paymentHash: string): Promise<Invoice | null> {
    if (!this.methods.size || this.methods.has('lookup_invoice')) {
      const tx = await this.lookup({ payment_hash: paymentHash });
      return tx ? toInvoice(tx) : null;
    }
    return this.notSupported('invoice lookup');
  }

  /**
   * Purely local — no relay round-trip, and it works while the wallet is unreachable. The invoice is
   * self-describing, so there is nothing the wallet service could add beyond what the bytes already say.
   */
  override async decodeInvoice(bolt11: string): Promise<DecodedInvoice> {
    return decodeBolt11(bolt11);
  }

  override async getPayments(opts?: { limit?: number }): Promise<Payment[]> {
    const res = await this.run('list_transactions', (client) =>
      client.listTransactions({ type: 'outgoing', limit: opts?.limit, unpaid_outgoing: true }),
    );
    return (res.transactions ?? []).filter((tx) => tx.type !== 'incoming').map(toPayment);
  }

  override async payInvoice(req: PayInvoiceRequest): Promise<Payment> {
    if (!this.caps.has('lightningSend')) return this.notSupported('lightning send');
    // Fee limits and timeouts are the wallet's own budget policy; NIP-47 has no field for either.
    const amount = req.amountMsat ? toMsatNumber(req.amountMsat, 'amountMsat') : undefined;
    const res = await this.run('pay_invoice', (client) => client.payInvoice({ invoice: req.bolt11, amount }));

    const paymentHash = hashFromPreimage(res.preimage);
    // pay_invoice returns only {preimage, fees_paid}. Recover the rest from the wallet's own record
    // when it keeps one — best effort, never fatal, since the payment has already settled.
    const record = paymentHash ? await this.lookupQuietly({ payment_hash: paymentHash }) : null;

    return {
      paymentHash,
      preimage: res.preimage || null,
      amountMsat: record?.amount != null ? String(record.amount) : (req.amountMsat ?? '0'),
      feeMsat: String(res.fees_paid ?? record?.fees_paid ?? 0),
      // A NIP-47 pay_invoice that resolves has succeeded; a failure arrives as a Nip47WalletError.
      status: 'succeeded',
      createdAt: record?.created_at ?? now(),
      destination: null,
      memo: record?.description || null,
      failureReason: null,
    };
  }

  override async sendKeysend(req: KeysendRequest): Promise<Payment> {
    if (!this.caps.has('keysend')) return this.notSupported('keysend');
    const amount = toMsatNumber(req.amountMsat, 'amountMsat');
    // NIP-47 carries TLV values as hex.
    const tlvRecords = req.message
      ? [{ type: KEYSEND_MESSAGE_TLV, value: Buffer.from(req.message, 'utf8').toString('hex') }]
      : undefined;

    const res = await this.run('pay_keysend', (client) =>
      client.payKeysend({ pubkey: req.destination, amount, tlv_records: tlvRecords }),
    );
    return {
      paymentHash: hashFromPreimage(res.preimage),
      preimage: res.preimage || null,
      amountMsat: req.amountMsat,
      feeMsat: String(res.fees_paid ?? 0),
      status: 'succeeded',
      createdAt: now(),
      destination: req.destination,
      memo: req.message ?? null,
      failureReason: null,
    };
  }

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

  private async lookup(request: { payment_hash?: string; invoice?: string }): Promise<NwcTransaction | null> {
    try {
      return await this.run('lookup_invoice', (client) => client.lookupInvoice(request));
    } catch (err) {
      // A wallet that has never seen the hash answers NOT_FOUND; that is an absence, not a failure.
      if (err instanceof BackendError && err.status === 404) return null;
      throw err;
    }
  }

  private async lookupQuietly(request: { payment_hash?: string; invoice?: string }): Promise<NwcTransaction | null> {
    if (this.methods.size && !this.methods.has('lookup_invoice')) return null;
    try {
      return await this.lookup(request);
    } catch {
      return null;
    }
  }
}
