import {
  listWallets,
  getWallet,
  getSealedSeed,
  createWallet,
  updateWallet,
  replaceSealedSeed,
  setActiveWallet,
  deleteWallet,
  getActiveWallet,
  getWalletLabels,
  setWalletLabel,
  getFrozenOutpoints,
  setUtxoFrozen,
  type WalletKind,
} from 'officerdb';
import { resolveBackend, invalidate } from './resolve';
import {
  changePassphrase,
  deriveAccountXpubs,
  exportMnemonic,
  generateSeed,
  sealSeed,
  sessionFor,
  verifyPassphrase,
  type SeedEnvelope,
} from './keys';
import { getConfig, hasStoreKey } from './upstream';
import {
  asAddressType,
  BackendError,
  WalletLockedError,
  BIP_ADDRESS_TYPE,
  type BitcoinNetwork,
  type Capability,
  type Balances,
  type SyncState,
  type Utxo,
  type WalletBackend,
} from './types';

// The wallet sidecar's route surface. Every route is scoped to the authenticated owner via X-Officer-User,
// which the platform proxy injects (src/servers/api/wallet/router.ts) and which is trustworthy because the
// sidecar binds loopback only.
//
// Route ordering matters here: the seed/lock routes are matched BEFORE the generic wallet operations, so a
// wallet named "unlock" can never shadow the unlock endpoint.
//
// SECURITY NOTES that apply to this whole file:
//   - No route ever returns a mnemonic, a sealed envelope, a node macaroon, or an unlock passphrase,
//     except /export-seed, which exists for backup and demands the passphrase every single time.
//   - Passphrases arrive in request bodies and are never logged. The catch-all handler at the bottom logs
//     the method and path only, deliberately not the body.
//   - Capability checks happen before dispatch so an unsupported operation is a clean 501.

export type OfficerContext = { req: Request; url: URL; userId: number };

function json(data: unknown, status = 200): Response {
  return Response.json(data as Record<string, unknown>, { status });
}

function badRequest(message: string): Response {
  return json({ error: message }, 400);
}

async function body<T>(req: Request): Promise<T> {
  try {
    return (await req.json()) as T;
  } catch {
    throw new BackendError('expected a JSON body', 400, 'BAD_BODY');
  }
}

/**
 * Freshness of the data a read just returned, or null from a backend that has no cache to be stale.
 * Attached to balances, transactions and utxos so the UI can distinguish an empty wallet from one it
 * could not reach — those rendered identically before, as a zero balance.
 *
 * Must be read AFTER the response it describes: it reports the snapshot that was actually served.
 */
function syncOf(backend: WalletBackend): SyncState | null {
  return backend.getSyncState?.() ?? null;
}

/** Guard a capability before dispatching, so callers get 501 rather than a confusing upstream error. */
function requireCap(backend: WalletBackend, cap: Capability, op: string): void {
  if (!backend.supports(cap)) {
    throw new BackendError(`this wallet does not support ${op}`, 501, 'NOT_SUPPORTED');
  }
}

// ── entry point ──────────────────────────────────────────────────────────────────────────────────

export async function handleOfficerRoute(req: Request, url: URL): Promise<Response | null> {
  const officerUser = req.headers.get('X-Officer-User');
  if (!officerUser) return json({ error: 'missing X-Officer-User' }, 401);
  const userId = Number(officerUser);
  if (!Number.isInteger(userId) || userId <= 0) return badRequest('invalid X-Officer-User');

  const segments = url.pathname.slice('/_officer/'.length).split('/').filter(Boolean);
  if (segments.length === 0) return null;
  const ctx: OfficerContext = { req, url, userId };

  try {
    switch (segments[0]) {
      case 'config':
        return handleConfig();
      case 'wallets':
        return await handleWallets(ctx, segments.slice(1));
      default:
        return null;
    }
  } catch (err) {
    if (err instanceof BackendError) {
      return json({ error: err.message, code: err.code }, err.status);
    }
    throw err;
  }
}

/**
 * Non-secret deployment facts the UI needs before any wallet exists.
 *
 * No esplora URL: that is per-owner now and lives at `/_config`, which is also the only place that can
 * say whether it is the owner's endpoint or the fallback. Reporting the default here would have shown
 * the wrong URL to anyone who had set their own.
 */
function handleConfig(): Response {
  const cfg = getConfig();
  return json({
    network: cfg.network,
    unlockTtlSec: cfg.unlockTtlSec,
    // The UI blocks wallet creation on this rather than letting the first write fail on a crypto error.
    storeKeyConfigured: hasStoreKey(),
  });
}

// ── /wallets ─────────────────────────────────────────────────────────────────────────────────────

const KINDS: readonly WalletKind[] = ['onchain', 'lnd', 'cln-rest', 'lndhub', 'nwc'];

/** Highest gap limit a rescan may be asked for. Four variants at this width is already tens of minutes. */
const MAX_GAP_LIMIT = 100_000;

async function handleWallets(ctx: OfficerContext, seg: string[]): Promise<Response | null> {
  const { req, userId } = ctx;

  // /_officer/wallets
  if (seg.length === 0) {
    if (req.method === 'GET') return json({ wallets: await listWallets(userId) });
    if (req.method === 'POST') return await createWalletRoute(ctx);
    return json({ error: 'method not allowed' }, 405);
  }

  // /_officer/wallets/active — resolves to whichever wallet is currently selected
  if (seg[0] === 'active' && seg.length === 1 && req.method === 'GET') {
    const active = await getActiveWallet(userId);
    return json({ wallet: active });
  }

  const walletId = Number(seg[0]);
  if (!Number.isInteger(walletId) || walletId <= 0) return badRequest('invalid wallet id');
  const rest = seg.slice(1);

  // /_officer/wallets/:id
  if (rest.length === 0) {
    if (req.method === 'GET') {
      const wallet = await getWallet(userId, walletId);
      return wallet ? json({ wallet }) : json({ error: 'wallet not found' }, 404);
    }
    if (req.method === 'PATCH') {
      const patch = await body<{ name?: string; defaultBip?: number; config?: Record<string, unknown> }>(req);
      // A rename goes through the same rule as a create: trimmed, and never blank. Without this a stray
      // empty string would leave a wallet with no name anywhere in the UI and no way to type one back.
      if (patch.name !== undefined) {
        const name = patch.name.trim();
        if (!name) return badRequest('name cannot be empty');
        if (name.length > 64) return badRequest('name is too long (64 characters max)');
        patch.name = name;
      }
      // Rebuilt field by field rather than forwarded: `body<T>` is a cast, so the parsed object holds
      // whatever the caller sent, not what the type says. updateWallet can no longer write the seed
      // either — that is the belt to this brace.
      const updated = await updateWallet(userId, walletId, {
        name: patch.name,
        defaultBip: patch.defaultBip,
        config: patch.config,
      });
      invalidate(walletId);
      return updated ? json({ wallet: updated }) : json({ error: 'wallet not found' }, 404);
    }
    if (req.method === 'DELETE') return await deleteWalletRoute(ctx, walletId);
    return json({ error: 'method not allowed' }, 405);
  }

  // Seed / lock lifecycle — matched before the generic operations below.
  switch (rest[0]) {
    case 'activate':
      if (req.method !== 'POST') return json({ error: 'method not allowed' }, 405);
      await setActiveWallet(userId, walletId);
      return json({ ok: true });

    case 'lock-state': {
      const wallet = await getWallet(userId, walletId);
      if (!wallet) return json({ error: 'wallet not found' }, 404);
      const session = sessionFor(walletId);
      return json({
        hasSeed: wallet.hasSeed,
        unlocked: session.isUnlocked(),
        secondsRemaining: session.secondsRemaining(),
      });
    }

    case 'unlock':
      return await unlockRoute(ctx, walletId);

    case 'lock':
      if (req.method !== 'POST') return json({ error: 'method not allowed' }, 405);
      sessionFor(walletId).lock();
      return json({ ok: true, unlocked: false });

    case 'passphrase':
      return await changePassphraseRoute(ctx, walletId);

    case 'export-seed':
      return await exportSeedRoute(ctx, walletId);
  }

  // Everything else needs a live backend.
  const { wallet, backend } = await resolveBackend(userId, walletId);

  switch (rest[0]) {
    case 'capabilities': {
      const all: Capability[] = [
        'onchainReceive',
        'onchainSend',
        'coinControl',
        'psbt',
        'bumpFee',
        'sweep',
        'accounts',
        'lightningReceive',
        'lightningSend',
        'keysend',
        'customPreimages',
        'offers',
        'channels',
        'peers',
        'routing',
        'signMessage',
      ];
      return json({ kind: wallet.kind, capabilities: all.filter((c) => backend.supports(c)) });
    }

    case 'info':
      return json({ info: await backend.getInfo() });

    case 'balances':
      return json({ balances: await balancesWithFrozen(walletId, backend), sync: syncOf(backend) });

    case 'transactions': {
      const limit = Number(ctx.url.searchParams.get('limit') ?? 50);
      const txs = await backend.getTransactions({ limit });
      // Overlay owner labels, which live in Officer's DB rather than any backend.
      const labels = await getWalletLabels(walletId);
      const byRef = new Map(labels.filter((l) => l.kind === 'tx').map((l) => [l.ref, l.label]));
      return json({
        transactions: txs.map((t) => ({ ...t, label: byRef.get(t.txid) ?? t.label })),
        sync: syncOf(backend),
      });
    }

    case 'address': {
      requireCap(backend, 'onchainReceive', 'receiving on-chain');
      const peek = ctx.url.searchParams.get('peek') === 'true';
      // An explicit ?type= wins; otherwise the wallet's defaultBip decides. Without this the on-chain
      // backend would always fall back to its own preference order (native segwit), silently ignoring an
      // owner who set the wallet to taproot or legacy.
      const asked = ctx.url.searchParams.get('type');
      if (asked && !asAddressType(asked)) return badRequest(`unknown address type "${asked}"`);
      const type = asked ? asAddressType(asked) : BIP_ADDRESS_TYPE[String(wallet.defaultBip)];
      return json(await backend.getNewAddress({ peek, type }));
    }

    case 'utxos':
      return await utxosRoute(ctx, walletId, backend, rest.slice(1));

    // POST starts a deep rescan, GET reads how it is going. Both answer with the same `rescan` block that
    // rides on balances/transactions/utxos, so the UI has one shape to render and can poll whichever it
    // was already polling. A backend with nothing to rescan is a clean 501 rather than a silent no-op.
    case 'rescan': {
      // No local state means either "never rescanned" or "this sidecar restarted while one was running",
      // and only the upstream can tell those apart. Probing costs a request per account, so it happens
      // only in that gap — once a scan is adopted or finished, the local state answers.
      if (ctx.req.method === 'GET') {
        const local = syncOf(backend)?.rescan ?? null;
        return json({ rescan: local ?? (await backend.adoptRescan?.()) ?? null });
      }
      if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405);
      if (!backend.startRescan) {
        throw new BackendError('this wallet has nothing to rescan', 501, 'NOT_SUPPORTED');
      }
      // The gap limit is the one thing about a rescan only the owner can know — how many addresses their
      // old wallet issued without ever being paid to. The ceiling is a node-CPU guard, not a correctness
      // one: past it the scan takes longer than anyone will wait for it.
      const { gapLimit } = await body<{ gapLimit?: number }>(ctx.req);
      if (gapLimit !== undefined && (!Number.isInteger(gapLimit) || gapLimit < 1 || gapLimit > MAX_GAP_LIMIT)) {
        return badRequest(`gapLimit must be a whole number between 1 and ${MAX_GAP_LIMIT}`);
      }
      return json({ rescan: await backend.startRescan({ gapLimit }) });
    }

    case 'fees':
      return json({ fees: await backend.estimateFees() });

    case 'send': {
      if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405);
      requireCap(backend, 'onchainSend', 'sending on-chain');
      // The lock gate comes before body validation, not after. The backend checks it too, but only once
      // the request has already passed every field check here — so a locked wallet was answering "your
      // fee rate is wrong" instead of "unlock me first", which sends the UI down the wrong path.
      if (wallet.hasSeed && !sessionFor(walletId).isUnlocked()) throw new WalletLockedError();
      const req2 = await body<Parameters<WalletBackend['sendCoins']>[0]>(ctx.req);
      if (!req2.address) return badRequest('address is required');
      if (!req2.sendAll && !req2.amountSats) return badRequest('amountSats or sendAll is required');
      if (!req2.satPerVbyte || req2.satPerVbyte < 1) return badRequest('satPerVbyte must be at least 1');
      // Never spend a frozen coin, even if the caller passed no explicit outpoint list.
      const frozenList = await getFrozenOutpoints(walletId);
      const frozen = new Set(frozenList);
      if (req2.outpoints?.some((o) => frozen.has(o))) return badRequest('refusing to spend a frozen UTXO');
      // Spread last so a caller cannot supply its own frozen list: the check above only ever covered
      // outpoints the caller named explicitly, which left automatic selection free to pick a frozen coin.
      const result = await backend.sendCoins({ ...req2, frozenOutpoints: frozenList });
      if (req2.label) await setWalletLabel(walletId, 'tx', result.txid, req2.label);
      return json(result);
    }

    case 'invoices':
      return await invoicesRoute(ctx, backend, rest.slice(1));

    case 'decode': {
      if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405);
      const { bolt11 } = await body<{ bolt11?: string }>(ctx.req);
      if (!bolt11) return badRequest('bolt11 is required');
      return json({ decoded: await backend.decodeInvoice(bolt11) });
    }

    case 'payments': {
      const limit = Number(ctx.url.searchParams.get('limit') ?? 50);
      return json({ payments: await backend.getPayments({ limit }) });
    }

    case 'pay': {
      if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405);
      requireCap(backend, 'lightningSend', 'paying lightning invoices');
      const payReq = await body<Parameters<WalletBackend['payInvoice']>[0]>(ctx.req);
      if (!payReq.bolt11) return badRequest('bolt11 is required');
      if (payReq.feeLimitMsat && payReq.feeLimitPercent) {
        return badRequest('feeLimitMsat and feeLimitPercent are mutually exclusive');
      }
      return json({ payment: await backend.payInvoice(payReq) });
    }

    case 'keysend': {
      if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405);
      requireCap(backend, 'keysend', 'keysend');
      const ks = await body<Parameters<WalletBackend['sendKeysend']>[0]>(ctx.req);
      if (!ks.destination || !ks.amountMsat) return badRequest('destination and amountMsat are required');
      return json({ payment: await backend.sendKeysend(ks) });
    }

    case 'channels':
      requireCap(backend, 'channels', 'channels');
      return json({ channels: await backend.getChannels() });

    case 'peers':
      requireCap(backend, 'peers', 'peers');
      return json({ peers: await backend.getPeers() });

    case 'sign': {
      if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405);
      requireCap(backend, 'signMessage', 'message signing');
      const { message } = await body<{ message?: string }>(ctx.req);
      if (!message) return badRequest('message is required');
      return json(await backend.signMessage(message));
    }

    case 'verify': {
      if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405);
      requireCap(backend, 'signMessage', 'message verification');
      const { message, signature } = await body<{ message?: string; signature?: string }>(ctx.req);
      if (!message || !signature) return badRequest('message and signature are required');
      return json(await backend.verifyMessage(message, signature));
    }

    case 'labels': {
      if (ctx.req.method === 'GET') return json({ labels: await getWalletLabels(walletId) });
      if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405);
      const { kind, ref, label } = await body<{ kind?: string; ref?: string; label?: string }>(ctx.req);
      if (kind !== 'address' && kind !== 'tx') return badRequest('kind must be "address" or "tx"');
      if (!ref) return badRequest('ref is required');
      await setWalletLabel(walletId, kind, ref, label ?? '');
      return json({ ok: true });
    }

    default:
      return null;
  }
}

// ── wallet lifecycle ─────────────────────────────────────────────────────────────────────────────

type CreateBody = {
  name?: string;
  kind?: string;
  network?: string;
  /** onchain only: omit `mnemonic` to generate a fresh seed. */
  mnemonic?: string;
  words?: 12 | 24;
  passphrase?: string;
  bip39Passphrase?: string;
  defaultBip?: number;
  config?: Record<string, unknown>;
  makeActive?: boolean;
};

async function createWalletRoute(ctx: OfficerContext): Promise<Response> {
  if (!hasStoreKey()) {
    throw new BackendError('VAULT_STORE_KEY is not configured; refusing to store wallet secrets', 503, 'NO_STORE_KEY');
  }
  const b = await body<CreateBody>(ctx.req);
  if (!b.name?.trim()) return badRequest('name is required');
  // Same cap as the rename route, so a name you can create is always a name you can type back.
  if (b.name.trim().length > 64) return badRequest('name is too long (64 characters max)');
  if (!b.kind || !(KINDS as readonly string[]).includes(b.kind)) {
    return badRequest(`kind must be one of ${KINDS.join(', ')}`);
  }
  const kind = b.kind as WalletKind;
  const network = (b.network ?? getConfig().network) as BitcoinNetwork;

  // Remote-node wallets: store the connection config, no seed involved.
  if (kind !== 'onchain') {
    if (!b.config) return badRequest(`${kind} wallets require a config object`);
    const wallet = await createWallet({
      userId: ctx.userId,
      name: b.name.trim(),
      kind,
      network,
      config: b.config,
      makeActive: b.makeActive ?? true,
    });
    return json({ wallet }, 201);
  }

  // Self-custodial on-chain wallet: seal a seed under the owner passphrase.
  if (!b.passphrase) return badRequest('passphrase is required for a seeded wallet');
  const mnemonic = b.mnemonic?.trim() || generateSeed(b.words ?? 24);
  const envelope = await sealSeed(mnemonic, b.passphrase, b.bip39Passphrase);
  const { fingerprint, xpubs } = await deriveAccountXpubs(envelope, b.passphrase, network);

  const wallet = await createWallet({
    userId: ctx.userId,
    name: b.name.trim(),
    kind,
    network,
    sealedSeed: JSON.stringify(envelope),
    fingerprint,
    xpubs: Object.fromEntries(Object.entries(xpubs).map(([k, v]) => [k, v])),
    defaultBip: b.defaultBip ?? 84,
    makeActive: b.makeActive ?? true,
  });

  // An IMPORTED seed is the one case that needs the chain searched from scratch: an indexing upstream
  // only watches an account from the moment it is registered, so a wallet with a history would otherwise
  // show a confident, wrong zero. A freshly generated seed has no history to find, and a rescan for it
  // would burn several minutes of the node's CPU to confirm nothing.
  if (b.mnemonic) void kickOffRescan(ctx.userId, wallet.id);

  // Return the mnemonic exactly once, and ONLY when we generated it — the owner has to write it down and
  // has no other chance to see it without re-entering the passphrase. An imported mnemonic is never
  // echoed back: the caller already has it, and echoing would put it in a response log for no reason.
  return json({ wallet, mnemonic: b.mnemonic ? undefined : mnemonic }, 201);
}

/**
 * Start a rescan behind the response that created the wallet.
 *
 * Never throws: a chain source that cannot rescan, or an upstream that is down, must not turn a
 * successful wallet import into a failed one. The owner can trigger it by hand from Settings either way.
 */
async function kickOffRescan(userId: number, walletId: number): Promise<void> {
  try {
    const { backend } = await resolveBackend(userId, walletId);
    await backend.startRescan?.();
  } catch (err) {
    console.error('[wallet] rescan after import failed to start:', err instanceof Error ? err.message : err);
  }
}

async function deleteWalletRoute(ctx: OfficerContext, walletId: number): Promise<Response> {
  const wallet = await getWallet(ctx.userId, walletId);
  if (!wallet) return json({ error: 'wallet not found' }, 404);

  // A bodyless DELETE is a caller that skipped the confirmation, not a malformed request — it should be
  // told which field is missing rather than that its JSON did not parse.
  type DeleteBody = { passphrase?: string; confirmName?: string };
  const { passphrase, confirmName } = await body<DeleteBody>(ctx.req).catch((): DeleteBody => ({}));

  // Deleting a seeded wallet destroys the only copy of the key material Officer holds. Require the
  // passphrase, even when the wallet is already unlocked — an open session must not be enough.
  if (wallet.hasSeed) {
    if (!passphrase) return badRequest('passphrase is required to delete a seeded wallet');
    const sealed = await getSealedSeed(ctx.userId, walletId);
    if (!sealed) throw new BackendError('wallet seed is missing', 500, 'NO_SEED');
    if (!(await verifyPassphrase(walletId, JSON.parse(sealed) as SeedEnvelope, passphrase))) {
      return json({ error: 'incorrect passphrase' }, 401);
    }
  } else if (confirmName?.trim() !== wallet.name) {
    // A wallet with no seed of its own still holds the node credential, the labels and the freezes, and
    // it deleted on one unconfirmed request — the name check lived only in the dialog, so anything that
    // was not the dialog skipped it. Naming the wallet is the same bar the UI already asks for, now
    // enforced where it cannot be bypassed.
    return badRequest('confirmName must match the wallet name');
  }

  sessionFor(walletId).lock();
  invalidate(walletId);
  const deleted = await deleteWallet(ctx.userId, walletId);
  return deleted ? json({ ok: true }) : json({ error: 'wallet not found' }, 404);
}

// ── lock lifecycle ───────────────────────────────────────────────────────────────────────────────

async function loadEnvelope(userId: number, walletId: number): Promise<SeedEnvelope> {
  const sealed = await getSealedSeed(userId, walletId);
  if (!sealed) throw new BackendError('this wallet holds no seed', 400, 'NO_SEED');
  return JSON.parse(sealed) as SeedEnvelope;
}

async function unlockRoute(ctx: OfficerContext, walletId: number): Promise<Response> {
  if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405);
  const { passphrase, ttlSec } = await body<{ passphrase?: string; ttlSec?: number }>(ctx.req);
  if (!passphrase) return badRequest('passphrase is required');

  const env = await loadEnvelope(ctx.userId, walletId);
  const session = sessionFor(walletId);
  const max = getConfig().unlockTtlSec;
  // A caller may shorten the window but never extend it past the deployment's configured maximum.
  await session.unlock(env, passphrase, Math.min(ttlSec ?? max, max));

  return json({ ok: true, unlocked: true, secondsRemaining: session.secondsRemaining() });
}

async function changePassphraseRoute(ctx: OfficerContext, walletId: number): Promise<Response> {
  if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405);
  const { oldPassphrase, newPassphrase } = await body<{ oldPassphrase?: string; newPassphrase?: string }>(ctx.req);
  if (!oldPassphrase || !newPassphrase) return badRequest('oldPassphrase and newPassphrase are required');

  const env = await loadEnvelope(ctx.userId, walletId);
  const resealed = await changePassphrase(walletId, env, oldPassphrase, newPassphrase);
  await replaceSealedSeed(ctx.userId, walletId, JSON.stringify(resealed));
  // Force a re-unlock under the new passphrase rather than leaving a session opened by the old one.
  sessionFor(walletId).lock();
  return json({ ok: true });
}

async function exportSeedRoute(ctx: OfficerContext, walletId: number): Promise<Response> {
  if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405);
  const { passphrase } = await body<{ passphrase?: string }>(ctx.req);
  if (!passphrase) return badRequest('passphrase is required');

  const env = await loadEnvelope(ctx.userId, walletId);
  const mnemonic = await exportMnemonic(walletId, env, passphrase);
  console.warn(`[wallet] seed exported for wallet ${walletId} by user ${ctx.userId}`);
  return json({ mnemonic, hasBip39Passphrase: env.hasBip39Passphrase });
}

/**
 * Balances, plus how much of the confirmed figure the owner has frozen.
 *
 * Only asks the backend for its UTXOs when something is actually frozen, so a wallet that never uses the
 * feature pays nothing for it — on LND that is a real extra RPC, and this runs on every balance poll.
 */
async function balancesWithFrozen(walletId: number, backend: WalletBackend): Promise<Balances> {
  const balances = await backend.getBalances();
  const frozenList = await getFrozenOutpoints(walletId);
  if (frozenList.length === 0) return balances;

  const frozen = new Set(frozenList);
  const utxos = await backend.getUtxos();
  // Confirmed only, to match what it is a component of. An unconfirmed frozen coin is not spendable for
  // a reason that has nothing to do with freezing.
  const sats = utxos
    .filter((u) => frozen.has(`${u.txid}:${u.vout}`) && u.confirmations > 0)
    .reduce((sum, u) => sum + u.amountSats, 0);
  return { ...balances, onchainFrozen: sats };
}

// ── utxos ────────────────────────────────────────────────────────────────────────────────────────

async function utxosRoute(
  ctx: OfficerContext,
  walletId: number,
  backend: WalletBackend,
  seg: string[],
): Promise<Response> {
  requireCap(backend, 'coinControl', 'coin control');

  if (seg[0] === 'freeze') {
    if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405);
    const { outpoint, frozen, reason } = await body<{ outpoint?: string; frozen?: boolean; reason?: string }>(ctx.req);
    if (!outpoint || !/^[0-9a-f]{64}:\d+$/i.test(outpoint)) return badRequest('outpoint must be "txid:vout"');
    await setUtxoFrozen(walletId, outpoint, frozen !== false, reason);
    return json({ ok: true });
  }

  const [utxos, frozenList, labels] = await Promise.all([
    backend.getUtxos(),
    getFrozenOutpoints(walletId),
    getWalletLabels(walletId),
  ]);
  const frozen = new Set(frozenList);
  const byAddr = new Map(labels.filter((l) => l.kind === 'address').map((l) => [l.ref, l.label]));

  // The freeze flag is Officer's, not the backend's — overlay it here so coin control is consistent
  // across every backend, including ones with no freeze concept of their own.
  const merged: (Utxo & { label: string | null })[] = utxos.map((u) => ({
    ...u,
    frozen: frozen.has(`${u.txid}:${u.vout}`),
    label: byAddr.get(u.address) ?? null,
  }));
  return json({ utxos: merged, sync: syncOf(backend) });
}

// ── invoices ─────────────────────────────────────────────────────────────────────────────────────

async function invoicesRoute(ctx: OfficerContext, backend: WalletBackend, seg: string[]): Promise<Response> {
  // /_officer/wallets/:id/invoices/:paymentHash
  if (seg.length === 1) {
    const invoice = await backend.lookupInvoice(seg[0]!);
    return invoice ? json({ invoice }) : json({ error: 'invoice not found' }, 404);
  }

  if (ctx.req.method === 'GET') {
    requireCap(backend, 'lightningReceive', 'lightning');
    const limit = Number(ctx.url.searchParams.get('limit') ?? 50);
    return json({ invoices: await backend.getInvoices({ limit }) });
  }

  if (ctx.req.method === 'POST') {
    requireCap(backend, 'lightningReceive', 'creating invoices');
    const req = await body<Parameters<WalletBackend['createInvoice']>[0]>(ctx.req);
    if (req.preimage) requireCap(backend, 'customPreimages', 'custom preimages');
    return json({ invoice: await backend.createInvoice(req) }, 201);
  }

  return json({ error: 'method not allowed' }, 405);
}
