import type { AddressType, BitcoinNetwork } from './types';
import { BackendError } from './types';

// NBXplorer client — the second chain source, alongside EsploraChain.
//
// THE MODEL IS DIFFERENT, AND THAT IS THE POINT. Esplora is address-level: you ask about one address at a
// time, so finding a wallet's coins means walking the gap limit yourself (43–160 requests per refresh, see
// backends/onchain.ts). NBXplorer is scheme-level: you register the account xpub once, it indexes the
// wallet server-side, and every question after that — balance, utxos, history, next unused address — is a
// single call. That is why this is not an implementation of EsploraChain's interface: there is no sensible
// per-address query to implement, and emulating one would throw away the entire advantage.
//
// It talks to a bitcoind that the owner runs. Everything below was verified against NBXplorer 2.6.9.

/**
 * NBXplorer names an account by its xpub plus a suffix for the script type. Native segwit is the bare
 * xpub; the rest carry a bracketed tag.
 *
 * Taproot is `-[taproot]`, which is NOT in NBXplorer's own README table — verified against a live 2.6.9
 * instance, which accepts it and derives the same addresses we do.
 */
const SCHEME_SUFFIX: Record<AddressType, string> = {
  p2wpkh: '',
  'p2sh-p2wpkh': '-[p2sh]',
  p2pkh: '-[legacy]',
  p2tr: '-[taproot]',
};

/**
 * The account identifier NBXplorer indexes under.
 *
 * The xpub must carry `xpub` version bytes for every script type — NOT ypub/zpub. That is already what
 * keys.ts::deriveAccountXpubs stores, and changing it would silently point NBXplorer at a different
 * account than the one we derive from.
 */
export const derivationScheme = (accountXpub: string, type: AddressType): string =>
  `${accountXpub}${SCHEME_SUFFIX[type]}`;

/** NBXplorer's two address roles. `Deposit` is the receive chain, `Change` is internal. */
export type DerivationFeature = 'Deposit' | 'Change';

export type NbxStatus = {
  chainHeight: number;
  syncHeight: number;
  isFullySynched: boolean;
  networkType: string;
  cryptoCode: string;
  version: string;
  bitcoinStatus?: { blocks: number; headers: number; verificationProgress: number; isSynched: boolean } | null;
};

export type NbxBalance = {
  /** Satoshis. NBXplorer emits plain numbers here, not strings. */
  unconfirmed: number;
  confirmed: number;
  total: number;
  immature: number;
  available: number;
};

export type NbxUtxo = {
  feature: DerivationFeature;
  /** `"<txid>-<vout>"`. */
  outpoint: string;
  index: number;
  transactionHash: string;
  scriptPubKey: string;
  address: string;
  /** Relative to the account, e.g. `"0/7"` — chain then index. */
  keyPath: string;
  keyIndex: number;
  value: number;
  confirmations: number;
  timestamp: number;
  redeem?: string | null;
};

type NbxUtxoGroup = { utxOs: NbxUtxo[]; spentOutpoints: string[] };

export type NbxUtxoChanges = {
  currentHeight: number;
  unconfirmed: NbxUtxoGroup;
  confirmed: NbxUtxoGroup;
  spentUnconfirmed: NbxUtxo[];
};

export type NbxTransaction = {
  transactionId: string;
  /** Raw hex. Present on wallet transactions, which is the only kind NBXplorer stores. */
  transaction?: string | null;
  blockHash?: string | null;
  confirmations: number;
  height?: number | null;
  timestamp: number;
  /** Signed satoshis: negative when the wallet spent. */
  balanceChange: number;
  replaceable?: boolean;
};

export type NbxTransactions = {
  height: number;
  confirmedTransactions: { transactions: NbxTransaction[] };
  unconfirmedTransactions: { transactions: NbxTransaction[] };
  replacedTransactions: { transactions: NbxTransaction[] };
};

export type NbxAddress = {
  feature: DerivationFeature;
  keyPath: string;
  scriptPubKey: string;
  address: string;
  index: number;
  redeem?: string | null;
};

/**
 * `GET …/utxos/scan` — how a `scantxoutset` sweep is going.
 *
 * Every field below `status` is optional on purpose: NBXplorer fills `progress` only once the node has
 * actually started, and a queued scan reports nothing but its place in the queue.
 */
export type NbxScanStatus = {
  status: 'Queued' | 'Pending' | 'Complete' | 'Error';
  error?: string | null;
  queuedAt?: string;
  progress?: {
    startedAt?: string;
    completedAt?: string | null;
    /** UTXOs pulled in. This is the number that answers "did the scan find my coins". */
    found?: number;
    batchNumber?: number;
    remainingBatches?: number;
    currentBatchProgress?: number;
    overallProgress?: number;
    remainingSeconds?: number;
    highestKeyIndexFound?: Partial<Record<DerivationFeature, number | null>>;
  } | null;
};

/**
 * `POST …/utxos/scan` parameters.
 *
 * The defaults are the values the node operator verified live. `gapLimit` 1000 is generous for any
 * wallet that has not used addresses beyond index 1000; raising it costs node CPU, not correctness.
 */
export type UtxoScanOptions = { batchSize?: number; gapLimit?: number; from?: number };

const SCAN_DEFAULTS = { batchSize: 1000, gapLimit: 1000, from: 0 } as const;

/** A failed broadcast comes back as HTTP 200 with `success: false` — never as an HTTP error. */
export type NbxBroadcastResult = {
  success: boolean;
  rpcCode?: number | null;
  rpcCodeMessage?: string | null;
  rpcMessage?: string | null;
};

const DEFAULT_TIMEOUT_MS = 20_000;

export type NbxplorerConfig = {
  baseUrl: string;
  network: BitcoinNetwork;
  /** Only ever 'BTC' here, but NBXplorer is multi-chain and the route carries it. */
  cryptoCode?: string;
  timeoutMs?: number;
};

type RequestInitLite = { method?: 'GET' | 'POST'; body?: string };

export class NbxplorerChain {
  readonly network: BitcoinNetwork;
  private readonly baseUrl: string;
  private readonly cryptoCode: string;
  private readonly timeoutMs: number;
  /** Schemes already registered this process. Tracking is idempotent but not free — see `track`. */
  private readonly tracked = new Set<string>();

  constructor(cfg: NbxplorerConfig) {
    this.baseUrl = cfg.baseUrl.replace(/\/+$/, '');
    this.network = cfg.network;
    this.cryptoCode = cfg.cryptoCode ?? 'BTC';
    this.timeoutMs = cfg.timeoutMs ?? DEFAULT_TIMEOUT_MS;
  }

  private get root(): string {
    return `/v1/cryptos/${this.cryptoCode}`;
  }

  /** Every request funnels through here, so a timeout and an upstream failure share one error shape. */
  private async send(path: string, init: RequestInitLite): Promise<{ status: number; body: string }> {
    const url = `${this.baseUrl}${path.startsWith('/') ? path : `/${path}`}`;
    const controller = new AbortController();
    const timer = setTimeout(() => controller.abort(), this.timeoutMs);

    let res: Response;
    try {
      res = await fetch(url, {
        method: init.method ?? 'GET',
        body: init.body,
        headers: init.body ? { 'Content-Type': 'application/json' } : undefined,
        signal: controller.signal,
      });
    } catch (err) {
      const msg = controller.signal.aborted ? `timed out after ${this.timeoutMs}ms` : String(err);
      throw new BackendError(`nbxplorer request ${path} failed: ${msg}`, 502, 'UPSTREAM_UNREACHABLE');
    } finally {
      clearTimeout(timer);
    }

    return { status: res.status, body: await res.text() };
  }

  private fail(path: string, status: number, body: string): never {
    // NBXplorer answers with {"code","message"} where it can, and bare text where it cannot.
    let message = body.slice(0, 300);
    try {
      const parsed = JSON.parse(body) as { message?: unknown };
      if (typeof parsed.message === 'string' && parsed.message) message = parsed.message;
    } catch {
      /* not its JSON envelope */
    }
    throw new BackendError(`nbxplorer ${status} on ${path}: ${message}`, status, 'NBXPLORER_ERROR');
  }

  private async raw(path: string, init: RequestInitLite = {}): Promise<string> {
    const { status, body } = await this.send(path, init);
    if (status < 200 || status >= 300) this.fail(path, status, body);
    return body;
  }

  private async json<T>(path: string, init?: RequestInitLite): Promise<T> {
    const body = await this.raw(path, init);
    return this.parse<T>(path, body);
  }

  /**
   * Like `json`, but a 404 is an answer rather than a failure.
   *
   * Only the scan-status endpoint needs this, and it needs it badly: NBXplorer 404s both when no scan is
   * running and once a finished scan's result has expired. Treating that as an error would turn the two
   * most ordinary moments of a rescan — before it starts, and a while after it ends — into upstream
   * failures on the wallet screen.
   */
  private async jsonOrNull<T>(path: string, init?: RequestInitLite): Promise<T | null> {
    const { status, body } = await this.send(path, init ?? {});
    if (status === 404) return null;
    if (status < 200 || status >= 300) this.fail(path, status, body);
    return this.parse<T>(path, body);
  }

  private parse<T>(path: string, body: string): T {
    try {
      return JSON.parse(body) as T;
    } catch {
      throw new BackendError(`nbxplorer returned non-JSON on ${path}: ${body.slice(0, 200)}`, 502, 'NBXPLORER_ERROR');
    }
  }

  private scheme(accountXpub: string, type: AddressType): string {
    return encodeURIComponent(derivationScheme(accountXpub, type));
  }

  // ── endpoints ──────────────────────────────────────────────────────────────────────────────────

  /** `GET /v1/cryptos/BTC/status` — the liveness probe, and the only call that needs no scheme. */
  getStatus(): Promise<NbxStatus> {
    return this.json<NbxStatus>(`${this.root}/status`);
  }

  /**
   * `POST …/derivations/{scheme}` — register an account so NBXplorer starts indexing it.
   *
   * THIS IS NOT OPTIONAL AND ITS ABSENCE IS SILENT. Querying an untracked scheme returns HTTP 200 with
   * every figure zeroed, which is indistinguishable from a real empty wallet — a registration that never
   * happened would read as "you have no coins". So every read path calls this first, and the in-process
   * set only skips the repeat calls, never the first one after a restart.
   */
  async track(accountXpub: string, type: AddressType): Promise<void> {
    const key = derivationScheme(accountXpub, type);
    if (this.tracked.has(key)) return;
    await this.raw(`${this.root}/derivations/${this.scheme(accountXpub, type)}`, { method: 'POST' });
    this.tracked.add(key);
  }

  /** `GET …/balance` — confirmed/unconfirmed/total for the whole account, in one call. */
  async getBalance(accountXpub: string, type: AddressType): Promise<NbxBalance> {
    await this.track(accountXpub, type);
    return this.json<NbxBalance>(`${this.root}/derivations/${this.scheme(accountXpub, type)}/balance`);
  }

  /** `GET …/utxos` — every unspent output with its keyPath, which is what the PSBT builder needs. */
  async getUtxos(accountXpub: string, type: AddressType): Promise<NbxUtxoChanges> {
    await this.track(accountXpub, type);
    return this.json<NbxUtxoChanges>(`${this.root}/derivations/${this.scheme(accountXpub, type)}/utxos`);
  }

  /** `GET …/transactions` — wallet history, already grouped by confirmation state. */
  async getTransactions(accountXpub: string, type: AddressType): Promise<NbxTransactions> {
    await this.track(accountXpub, type);
    return this.json<NbxTransactions>(`${this.root}/derivations/${this.scheme(accountXpub, type)}/transactions`);
  }

  /**
   * `GET …/addresses/unused` — the next address NBXplorer believes is unused.
   *
   * `reserve=true` makes it hand out the next one on the following call, which is the server-side
   * equivalent of the issued-index mark onchain.ts keeps. Leave it false to peek.
   */
  async getUnusedAddress(
    accountXpub: string,
    type: AddressType,
    feature: DerivationFeature = 'Deposit',
    reserve = false,
  ): Promise<NbxAddress> {
    await this.track(accountXpub, type);
    const q = `feature=${feature}&reserve=${reserve ? 'true' : 'false'}`;
    return this.json<NbxAddress>(`${this.root}/derivations/${this.scheme(accountXpub, type)}/addresses/unused?${q}`);
  }

  /**
   * `GET …/transactions/{txid}` — the raw hex of ONE transaction.
   *
   * Only wallet-relevant transactions exist here; an arbitrary mainnet txid 404s, verified. That is
   * enough for our only caller, which wants `nonWitnessUtxo` for a legacy input — the transaction that
   * funded our own address is by definition a wallet transaction.
   */
  async getTxHex(accountXpub: string, type: AddressType, txid: string): Promise<string> {
    await this.track(accountXpub, type);
    const info = await this.json<NbxTransaction>(
      `${this.root}/derivations/${this.scheme(accountXpub, type)}/transactions/${encodeURIComponent(txid)}`,
    );
    const hex = info.transaction?.trim();
    if (!hex || !/^[0-9a-f]+$/i.test(hex)) {
      throw new BackendError(`nbxplorer has no raw hex for tx ${txid}`, 502, 'NBXPLORER_ERROR');
    }
    return hex;
  }

  /**
   * `POST …/utxos/scan` — sweep the node's whole UTXO set for this account's coins.
   *
   * WHY THIS EXISTS AT ALL. Registering a scheme only makes NBXplorer index it *from now on*. An
   * imported xpub with a history therefore reads as a real, quiet, zero-balance wallet: every call
   * succeeds, nothing errors, and the coins are simply not there. This endpoint is the fix — it runs
   * bitcoind's `scantxoutset`, which walks the current UTXO set directly and finds coins regardless of
   * when the account was registered or how far back the node is pruned.
   *
   * It finds spendable coins, NOT history. Spent-transaction history predating registration stays
   * missing; recovering that is a block rescan, which is a different and much heavier thing.
   *
   * Returns as soon as the scan is queued. `scantxoutset` is single-threaded and IO-heavy on the node,
   * so concurrent scans queue and run one after another — poll `getUtxoScanStatus` for the outcome.
   */
  async startUtxoScan(accountXpub: string, type: AddressType, opts: UtxoScanOptions = {}): Promise<void> {
    await this.track(accountXpub, type);
    const q = new URLSearchParams({
      batchSize: String(opts.batchSize ?? SCAN_DEFAULTS.batchSize),
      gapLimit: String(opts.gapLimit ?? SCAN_DEFAULTS.gapLimit),
      from: String(opts.from ?? SCAN_DEFAULTS.from),
    });
    await this.raw(`${this.root}/derivations/${this.scheme(accountXpub, type)}/utxos/scan?${q}`, { method: 'POST' });
  }

  /**
   * `GET …/utxos/scan` — the running scan, or null.
   *
   * Null means "nothing to report": no scan is running, or one finished long enough ago that NBXplorer
   * has dropped the result. Both are 404s and neither is a failure, so poll promptly and read a null
   * after a `Complete` as "it is over", not as "it vanished".
   */
  getUtxoScanStatus(accountXpub: string, type: AddressType): Promise<NbxScanStatus | null> {
    return this.jsonOrNull<NbxScanStatus>(`${this.root}/derivations/${this.scheme(accountXpub, type)}/utxos/scan`);
  }

  /** `GET /v1/cryptos/BTC/fees/{blockCount}` — one target per call, sat/vB as a float. */
  async getFeeRate(blockCount: number): Promise<number> {
    const res = await this.json<{ feeRate: number; blockCount: number }>(`${this.root}/fees/${blockCount}`);
    if (!Number.isFinite(res.feeRate) || res.feeRate <= 0) {
      throw new BackendError(`nbxplorer returned a bad fee rate for ${blockCount} blocks`, 502, 'NBXPLORER_ERROR');
    }
    return res.feeRate;
  }

  /**
   * `POST /v1/cryptos/BTC/transactions` — broadcast.
   *
   * The body is `{hex}`: the handler accepts raw bytes, a bare JSON string or this object, and the object
   * is the only one of the three with no parsing ambiguity. A REJECTED broadcast still returns HTTP 200
   * with `success: false`, so the status code alone would report a bounced transaction as sent.
   */
  async broadcast(rawHex: string): Promise<void> {
    const res = await this.json<NbxBroadcastResult>(`${this.root}/transactions`, {
      method: 'POST',
      body: JSON.stringify({ hex: rawHex }),
    });
    if (!res.success) {
      const detail = res.rpcCodeMessage || res.rpcMessage || 'the node rejected the transaction';
      throw new BackendError(`broadcast failed: ${detail}`, 502, 'BROADCAST_REJECTED');
    }
  }
}
