import { deleteServiceConnection, getServiceConnection, saveServiceConnection } from 'officerdb';
import { invalidateAll } from './resolve';
import type { ChainKind } from './upstream';
import { CHAIN_KINDS, getChainSource, getConfig, invalidateChainSource, normalizeEsploraUrl } from './upstream';
import type { BitcoinNetwork } from './types';

// `/_config` — which endpoint this owner's on-chain wallets read the chain from, and which protocol it
// speaks.
//
// This is the one wallet setting that is NOT per-wallet and NOT key material: an endpoint serves every
// on-chain wallet the owner has, and it holds no secret, because both supported kinds are unauthenticated
// read APIs. It went from an env var to a row for one reason — it is the setting most likely to need
// changing, and the reason to change it (a public explorer rate-limiting you, or seeing every address you
// own) shows up long after the deployment that set it.
//
// TWO KINDS, ONE ROW. Esplora and NBXplorer are stored as separate services but are mutually exclusive:
// saving one deletes the other, so "which endpoint is in use" never depends on precedence. See
// upstream.ts::getChainSource.
//
// PRIVACY, NOT CUSTODY. A chain endpoint learns which addresses you ask about, so pointing this at your
// own indexer is a real privacy upgrade. It is never a custody risk: the endpoint sees no key, and a
// hostile one can lie about balances but cannot move a coin.

export type ChainProbe = { ok: boolean; blockHeight?: number | null; error?: string; ms?: number };

/** What each kind reports itself as, versus what this deployment is configured for. */
const NETWORK_ALIASES: Record<BitcoinNetwork, readonly string[]> = {
  bitcoin: ['mainnet', 'main', 'bitcoin'],
  testnet: ['testnet', 'test'],
  signet: ['signet'],
  regtest: ['regtest'],
};

/** Does this endpoint answer, does it speak the protocol we think, and is it on OUR chain? */
export async function probe(baseUrl: string, kind: ChainKind): Promise<ChainProbe> {
  return kind === 'nbxplorer' ? probeNbxplorer(baseUrl) : probeEsplora(baseUrl);
}

/**
 * `/blocks/tip/height` is the cheapest endpoint Esplora has and the one every implementation has — a
 * bare number in the body. Anything else answering (a bitcoind RPC port, a mempool.space *web* root, an
 * nginx default page) fails here rather than at the first address scan, which is the whole point: the
 * owner finds out while looking at the form.
 *
 * Esplora does not say which chain it serves, so unlike NBXplorer there is nothing to cross-check here.
 */
async function probeEsplora(baseUrl: string): Promise<ChainProbe> {
  const started = Date.now();
  try {
    const res = await fetch(`${baseUrl}/blocks/tip/height`, {
      headers: { Accept: 'text/plain' },
      signal: AbortSignal.timeout(8_000),
      redirect: 'manual',
    });
    const body = (await res.text()).trim();
    if (!res.ok) {
      return { ok: false, error: `endpoint returned ${res.status}: ${body.slice(0, 200)}`, ms: Date.now() - started };
    }
    const height = Number(body);
    if (!Number.isFinite(height) || height <= 0) {
      return {
        ok: false,
        error: `that URL answered, but not like an Esplora API (expected a block height, got "${body.slice(0, 60)}")`,
        ms: Date.now() - started,
      };
    }
    return { ok: true, blockHeight: height, ms: Date.now() - started };
  } catch (err) {
    return { ok: false, error: `could not reach the endpoint (${String(err)})`, ms: Date.now() - started };
  }
}

/**
 * `/v1/cryptos/BTC/status` — NBXplorer's own liveness endpoint, and richer than Esplora's.
 *
 * It names the chain it is indexing, so a testnet NBXplorer accepted for a mainnet wallet is caught HERE
 * rather than by the owner wondering why their balance is zero. That mistake is easy to make (one docker
 * flag) and expensive to discover late, so the mismatch is a hard failure, not a warning.
 *
 * A node still catching up is allowed through with its height reported: it will be correct shortly, and
 * refusing to save a config because of a temporary sync state would be its own kind of wrong.
 */
async function probeNbxplorer(baseUrl: string): Promise<ChainProbe> {
  const started = Date.now();
  try {
    const res = await fetch(`${baseUrl}/v1/cryptos/BTC/status`, {
      headers: { Accept: 'application/json' },
      signal: AbortSignal.timeout(8_000),
      redirect: 'manual',
    });
    const body = (await res.text()).trim();
    if (!res.ok) {
      return { ok: false, error: `endpoint returned ${res.status}: ${body.slice(0, 200)}`, ms: Date.now() - started };
    }

    let status: { chainHeight?: unknown; networkType?: unknown; isFullySynched?: unknown };
    try {
      status = JSON.parse(body) as typeof status;
    } catch {
      return {
        ok: false,
        error: `that URL answered, but not like NBXplorer (expected JSON, got "${body.slice(0, 60)}")`,
        ms: Date.now() - started,
      };
    }

    const height = Number(status.chainHeight);
    if (!Number.isFinite(height) || height <= 0) {
      return { ok: false, error: 'that endpoint answered but reported no chain height', ms: Date.now() - started };
    }

    const want = getConfig().network;
    const got = String(status.networkType ?? '').toLowerCase();
    if (got && !NETWORK_ALIASES[want].includes(got)) {
      return {
        ok: false,
        error: `that NBXplorer is indexing ${got}, but this wallet is on ${want}. Refusing to point a ${want} wallet at a ${got} node.`,
        ms: Date.now() - started,
      };
    }

    return { ok: true, blockHeight: height, ms: Date.now() - started };
  } catch (err) {
    return { ok: false, error: `could not reach the endpoint (${String(err)})`, ms: Date.now() - started };
  }
}

const bad = (error: string, status = 400) => Response.json({ error }, { status });

const isHttpUrl = (url: string): boolean => /^https?:\/\//i.test(url);

/**
 * The endpoint in use, which protocol it speaks, and whether it is the owner's or the built-in default.
 * No secrets exist here at all.
 *
 * `esploraUrl` is kept as a field name despite now possibly holding an NBXplorer URL: it is the URL in
 * use, and renaming it would break the stored dashboard state of anything already reading it. `kind`
 * says what it actually is.
 */
async function state(userId: number): Promise<Response> {
  const source = await getChainSource(userId);
  const connection = source.custom ? await getServiceConnection(userId, source.kind) : null;
  return Response.json({
    configured: !!connection,
    connection,
    kind: source.kind,
    esploraUrl: source.baseUrl,
    custom: source.custom,
    defaultUrl: getConfig().esploraUrl,
    network: getConfig().network,
  });
}

type ConfigBody = { url?: unknown; kind?: unknown };

const isChainKind = (v: unknown): v is ChainKind =>
  typeof v === 'string' && (CHAIN_KINDS as readonly string[]).includes(v);

async function save(req: Request, userId: number): Promise<Response> {
  const body = ((await req.json().catch(() => null)) as ConfigBody | null) ?? {};
  const url = typeof body.url === 'string' ? normalizeEsploraUrl(body.url) : '';
  // Absent kind means a client written before the picker existed; those only ever spoke Esplora.
  const kind: ChainKind = isChainKind(body.kind) ? body.kind : 'esplora';

  if (!url) return bad('url is required');
  if (!isHttpUrl(url)) return bad('url must start with http:// or https://');

  const result = await probe(url, kind);
  if (!result.ok) return bad(result.error ?? 'that endpoint did not answer');

  await saveServiceConnection({ userId, service: kind, url });
  // Exactly one chain source exists at a time. Retiring the other kind here is what lets getChainSource
  // answer without a precedence rule, and what stops a stale row from resurfacing if this one is deleted.
  for (const other of CHAIN_KINDS) if (other !== kind) await deleteServiceConnection(userId, other);

  invalidateChainSource(userId);
  // Every on-chain backend holds a chain source built from the old URL, and a cached gap-limit scan taken
  // through it. Both are now wrong, so drop the instances rather than trying to patch them.
  invalidateAll();

  return Response.json({ ...(await state(userId).then((r) => r.json())), probe: result });
}

/** `/_config` — GET what is in use, PUT to change it, DELETE to fall back to the built-in default. */
export async function handleConfigRoute(req: Request, userId: number, subpath: string): Promise<Response> {
  if (subpath && subpath !== '/') return bad('not found', 404);

  if (req.method === 'GET') return state(userId);
  if (req.method === 'POST' || req.method === 'PUT') return save(req, userId);

  if (req.method === 'DELETE') {
    // Both, not just the active one: "reset to default" has to mean it regardless of which row exists.
    for (const kind of CHAIN_KINDS) await deleteServiceConnection(userId, kind);
    invalidateChainSource(userId);
    invalidateAll();
    return state(userId);
  }

  return bad('method not allowed', 405);
}
