/**
 * Phase 3b — Express handler that exposes a Registry over HTTP.
 *
 * Returns the Prometheus text exposition format (text/plain;
 * version=0.0.4) — same shape every node_exporter / cAdvisor / litellm
 * `/metrics` endpoint produces. Both gateway and worker mount this on
 * `/metrics`.
 *
 * Auth model (Phase 3b post-review hardening)
 * ───────────────────────────────────────────
 * The worker / gateway HTTP servers are user-facing, so /metrics MUST
 * NOT be wide open by default — labels like `team` and `key_prefix`
 * (= the first 8 chars of a stable virtual-key id) leak to anyone who
 * can reach the port. We therefore gate the handler with **two**
 * complementary mechanisms, both opt-in via config:
 *
 *   1. Bearer token (`metrics.bearer_token`): when set, requests must
 *      carry `Authorization: Bearer <token>`. Wins over the IP list —
 *      lets operators run Prometheus from an arbitrary subnet without
 *      embedding every scraper IP in config.
 *   2. Client-IP allowlist (`metrics.allowed_hosts`): when no bearer is
 *      configured, only requests whose `req.ip` is in the allowlist
 *      pass. Default `['127.0.0.1', '::1', 'localhost']` so a stock
 *      deploy can be scraped from localhost (sidecar / SSH tunnel) and
 *      nothing else.
 *
 * To open the endpoint to the world you either set a strong bearer
 * token or explicitly broaden `allowed_hosts`. There is no "no auth"
 * code path other than the default localhost allowlist.
 *
 * Errors during render are extremely rare (would require prom-client to
 * throw during string assembly) but if they do happen we surface a
 * generic 500 so the Prometheus scrape job records the failure
 * (`up == 0` for that target) instead of silently serving an empty
 * payload — a 200 with no metrics looks like "no data" in Grafana,
 * which is worse than an honest 500.
 */
import type { RequestHandler } from 'express';
import type { Registry } from 'prom-client';
import { logger } from '../logger.js';

export interface MetricsAuthOptions {
  /**
   * Optional bearer token. When present, requests must carry
   * `Authorization: Bearer <token>` or get a 401. Takes precedence
   * over the IP allowlist.
   */
  bearerToken?: string;
  /**
   * Client-IP allowlist applied when no bearer token is configured.
   * Defaults to `['127.0.0.1', '::1', 'localhost']` — localhost only.
   * Include `0.0.0.0` to disable IP checks entirely (only do this when
   * an outer reverse proxy / firewall handles access control).
   */
  allowedHosts?: string[];
}

const DEFAULT_ALLOWED_HOSTS: ReadonlyArray<string> = ['127.0.0.1', '::1', 'localhost'];

/**
 * Build a handler that scrapes the supplied Registry on each request.
 * The handler is async — prom-client's metrics() returns a Promise so
 * we await it before calling res.end.
 *
 * The optional `auth` argument enables Phase 3b post-review hardening:
 * bearer-token auth (preferred) or a client-IP allowlist (default
 * localhost-only). See module doc for the full model.
 */
export function createMetricsHandler(
  registry: Registry,
  auth?: MetricsAuthOptions,
): RequestHandler {
  const allowed = new Set(
    (auth?.allowedHosts && auth.allowedHosts.length > 0
      ? auth.allowedHosts
      : DEFAULT_ALLOWED_HOSTS) as string[],
  );
  const allowAny = allowed.has('0.0.0.0') || allowed.has('*');
  const bearerToken = auth?.bearerToken && auth.bearerToken.length > 0 ? auth.bearerToken : null;

  return async function metricsHandler(req, res, _next): Promise<void> {
    // 1. Bearer token (if configured) takes precedence over the IP
    //    allowlist. This is the path operators use when scraping from
    //    an arbitrary network — they trade IP discipline for token
    //    secrecy.
    if (bearerToken) {
      const authHeader = req.headers['authorization'];
      const expected = `Bearer ${bearerToken}`;
      if (typeof authHeader !== 'string' || authHeader !== expected) {
        res.set('WWW-Authenticate', 'Bearer realm="metrics"').status(401).end();
        return;
      }
    } else if (!allowAny) {
      // 2. Fall back to a client-IP allowlist. We accept the request when
      //    req.ip OR the raw remote address matches an entry. IPv4-mapped
      //    IPv6 (::ffff:127.0.0.1) is normalized so a localhost rule works
      //    on dual-stack hosts. `0.0.0.0` / `*` in the list disables the
      //    check for shaved-down configurations (e.g., reverse proxy in
      //    front of the gateway).
      const candidates: string[] = [];
      if (typeof req.ip === 'string' && req.ip.length > 0) {
        candidates.push(req.ip, req.ip.replace(/^::ffff:/, ''));
      }
      const remote = req.socket?.remoteAddress;
      if (typeof remote === 'string' && remote.length > 0) {
        candidates.push(remote, remote.replace(/^::ffff:/, ''));
      }
      const allowedHit = candidates.some(c => allowed.has(c));
      if (!allowedHit) {
        // 403, not 401: there's nothing the client can do — the IP isn't
        // on the list. WWW-Authenticate would mislead a bearer client
        // into retrying with a token that wouldn't be checked.
        res.status(403).end();
        return;
      }
    }

    try {
      const body = await registry.metrics();
      res.setHeader('Content-Type', registry.contentType);
      res.status(200).end(body);
    } catch (err) {
      // Logged at warn (not error) because a flaky default-metric
      // collector shouldn't pager the on-call. Prometheus side will
      // mark the target down and alert via its own rules.
      logger.warn(
        `[metrics-handler] failed to render metrics: ${err instanceof Error ? err.message : String(err)}`,
      );
      res.status(500).setHeader('Content-Type', 'text/plain; charset=utf-8');
      res.end('# failed to render metrics\n');
    }
  };
}
