import type { ChainCacheStore } from './backends/onchain';
import type { WalletChainSource } from './chain-source';
import type { ChainSource } from './upstream';
import {
  getWallet,
  getWalletSecrets,
  getWalletChainCache,
  saveWalletChainCache,
  recordWalletChainError,
  type WalletSummary,
} from 'officerdb';
import { EsploraChain } from './chain';
import { EsploraChainSource } from './chain-source-esplora';
import { NbxplorerChain } from './nbxplorer';
import { NbxplorerChainSource } from './chain-source-nbxplorer';
import { LndBackend } from './backends/lnd';
import { ClnRestBackend } from './backends/clnrest';
import { LndHubBackend } from './backends/lndhub';
import { NwcBackend } from './backends/nwc';
import { OnchainBackend } from './backends/onchain';
import { sessionFor } from './keys';
import { getChainSource } from './upstream';
import { BackendError, BIP_ADDRESS_TYPE, type AddressType, type BitcoinNetwork, type WalletBackend } from './types';

// Turns a stored wallet row into a live backend instance. This is the one place that knows the mapping
// from `kind` to a class, and the one place node credentials are decrypted — getWalletSecrets() is
// called here and the plaintext never travels further than the constructor it is handed to.
//
// Instances are cached per wallet id. Backends hold connection state worth reusing (LNDHub's access
// token, NWC's relay socket, the on-chain gap-limit scan), and rebuilding one per request would both
// re-authenticate constantly and defeat the address-scan cache. The cache is invalidated whenever the
// wallet's config changes — see `invalidate()`, called from the update/delete routes.

type Cached = { backend: WalletBackend; configVersion: string };
const cache = new Map<number, Cached>();

export function invalidate(walletId: number): void {
  cache.delete(walletId);
}

export function invalidateAll(): void {
  cache.clear();
}

/** A stable fingerprint of the inputs a backend was built from, so a stale instance is detected. */
function versionOf(wallet: WalletSummary, config: Record<string, unknown> | null, source: ChainSource): string {
  // The kind is in here as well as the URL: switching protocol at the same host is a different backend
  // with a differently-shaped cache behind it, and a fingerprint on the URL alone would miss it.
  return JSON.stringify([
    wallet.kind,
    wallet.network,
    wallet.defaultBip,
    wallet.xpubs,
    config,
    source.kind,
    source.baseUrl,
  ]);
}

export type Resolved = { wallet: WalletSummary; backend: WalletBackend };

export async function resolveBackend(userId: number, walletId: number): Promise<Resolved> {
  const wallet = await getWallet(userId, walletId);
  if (!wallet) throw new BackendError('wallet not found', 404, 'NOT_FOUND');

  const secrets = await getWalletSecrets(userId, walletId);
  const config = secrets?.config ?? null;
  // Read per resolve rather than per build: the source is in the fingerprint, so pointing the owner at a
  // different indexer rebuilds every backend and drops the scan taken through the old one.
  const source = await getChainSource(userId);
  const version = versionOf(wallet, config, source);

  const hit = cache.get(walletId);
  if (hit && hit.configVersion === version) return { wallet, backend: hit.backend };

  const backend = build(wallet, config, source);
  cache.set(walletId, { backend, configVersion: version });
  return { wallet, backend };
}

/**
 * Binds the chain cache queries to one wallet id. This is the only thing that gives the on-chain
 * backend a persistent identity — the backend itself never learns which wallet it is, exactly as it
 * never learns which seed it derives from.
 */
function chainCacheFor(walletId: number): ChainCacheStore {
  return {
    load: () => getWalletChainCache(walletId),
    save: (snapshot) => saveWalletChainCache(walletId, snapshot),
    recordError: (message) => recordWalletChainError(walletId, message),
  };
}

function required(config: Record<string, unknown> | null, key: string, kind: string): string {
  const v = config?.[key];
  if (typeof v !== 'string' || !v) {
    throw new BackendError(`${kind} wallet is missing required config "${key}"`, 400, 'BAD_CONFIG');
  }
  return v;
}

function build(wallet: WalletSummary, config: Record<string, unknown> | null, source: ChainSource): WalletBackend {
  const network = wallet.network as BitcoinNetwork;

  switch (wallet.kind) {
    case 'lnd':
      return new LndBackend({
        url: required(config, 'url', 'lnd'),
        macaroonHex: required(config, 'macaroonHex', 'lnd'),
        allowSelfSigned: config?.allowSelfSigned === true,
      });

    case 'cln-rest':
      return new ClnRestBackend({
        url: required(config, 'url', 'cln-rest'),
        rune: required(config, 'rune', 'cln-rest'),
        allowSelfSigned: config?.allowSelfSigned === true,
      });

    case 'lndhub':
      return new LndHubBackend({
        url: required(config, 'url', 'lndhub'),
        login: required(config, 'login', 'lndhub'),
        password: required(config, 'password', 'lndhub'),
      });

    case 'nwc':
      return new NwcBackend({ connectionUri: required(config, 'connectionUri', 'nwc') });

    case 'onchain': {
      // Every xpub the wallet holds is handed over, not just the default BIP's. A seed derives all four
      // accounts (keys.ts::deriveAccountXpubs), and coins can legitimately sit on any of them — a
      // recovered seed may have been used with a p2tr wallet before, or received to a legacy address.
      // Scanning only the default account would silently under-report the balance and leave those UTXOs
      // unspendable. `defaultBip` then means only "which script type new receive addresses use".
      const accountXpub: Partial<Record<AddressType, string>> = {};
      for (const [bip, type] of Object.entries(BIP_ADDRESS_TYPE)) {
        const xpub = wallet.xpubs?.[bip];
        if (xpub) accountXpub[type] = xpub;
      }
      if (Object.keys(accountXpub).length === 0) {
        throw new BackendError('wallet has no account xpubs', 500, 'BAD_CONFIG');
      }
      return new OnchainBackend({
        chain: chainSourceFor(source, network),
        network,
        accountXpub,
        // The session is the signer. While locked it holds no key material, so watch-only reads below
        // still work and only sendCoins/signMessage will throw WalletLockedError.
        signer: sessionFor(wallet.id),
        cache: chainCacheFor(wallet.id),
      });
    }

    default:
      throw new BackendError(`unknown wallet kind "${wallet.kind}"`, 400, 'BAD_CONFIG');
  }
}

/**
 * The one place the owner's chosen protocol turns into an implementation. Both satisfy the same
 * interface, and OnchainBackend cannot tell which it was handed — that is the entire point of the seam
 * (chain-source.ts). Adding a third kind is a case here and a file next to it, and nothing else.
 */
function chainSourceFor(source: ChainSource, network: BitcoinNetwork): WalletChainSource {
  const label = `${source.kind}(${hostOf(source.baseUrl)})`;

  if (source.kind === 'nbxplorer') {
    return new NbxplorerChainSource({
      chain: new NbxplorerChain({ baseUrl: source.baseUrl, network }),
      network,
      label,
    });
  }

  return new EsploraChainSource({ chain: new EsploraChain({ baseUrl: source.baseUrl, network }), label });
}

/** Host only, for a chain-source label. A malformed URL is labelled with itself rather than throwing. */
function hostOf(url: string): string {
  try {
    return new URL(url).host;
  } catch {
    return url;
  }
}
