/**
 * Gateway-mode configuration schema + validation.
 *
 * Lives in `gateway.*` of the same config.yaml AAO already uses, so worker
 * mode and gateway mode share one source of truth. The schema is read
 * after `transformKeys` (snake_case → camelCase) so YAML keys like
 * `listen_port` / `max_slots` arrive here as `listenPort` / `maxSlots`.
 *
 * Phase 1 keeps this static: virtual keys / backends are reloaded only
 * when ConfigManager fires a config-changed event (or the gateway
 * restarts). DB-backed keys + rotation are Phase 2 scope (see design
 * doc 2026-05-18-aao-gateway-mode-design.md).
 */
import type { AppConfig } from '../config.js';

export interface GatewayBackendConfig {
  /** Stable id surfaced as `x-aao-backend-id` and `/v1/models` entry. */
  id: string;
  /** Upstream base URL — `/v1` suffix required so /chat/completions resolves cleanly. */
  endpoint: string;
  /** Model name this backend serves. The gateway routes by strict equality on `request.model`. */
  model: string;
  /** Concurrent slots the upstream can serve. Mirrors llama-server's `-np`. */
  maxSlots: number;
  /** Optional bearer to forward to the backend (Ollama is usually unauthed). */
  apiKey?: string;
}

export interface GatewayVirtualKey {
  /** Bearer string a client sends in `Authorization: Bearer <key>`. */
  key: string;
  /** Human label used in logs and (Phase 2) budget reporting. */
  team: string;
  /** Optional allowlist of model ids this key may request. Undefined = any backend.model. */
  allowedModels?: string[];
  /**
   * Phase 2b: optional monthly tokens budget for this key. Omit = unlimited.
   * Imported into the DB row at boot by config-migration; admins can
   * override later via PATCH /api/admin/gateway/keys/:id.
   */
  tokensBudget?: number;
  /**
   * Phase 2b: optional per-key requests-per-minute cap. Omit = unlimited.
   * Process-local sliding window; multi-instance setups don't share state
   * (Phase 3 if needed).
   */
  rateLimitRpm?: number;
}

export interface GatewayRegistryConfig {
  /** Active polling cadence — when at least one client has hit recently. Default 5000. */
  pollIntervalMs?: number;
  /** Idle polling cadence — when nothing has hit. Default 30000. */
  idlePollIntervalMs?: number;
}

/**
 * Phase 3b: Prometheus `/metrics` exporter settings.
 *
 * Default behaviour is "on" — most operators want metrics. Set
 * `enabled: false` only for shaved-down standalone deploys where the
 * extra MB of prom-client + scrape surface area isn't desired.
 *
 * Prefix is exposed as a config knob so two AAO instances scraped by
 * the same Prometheus job can be told apart at the metric-name level
 * (e.g. `aao_gateway_a_` / `aao_gateway_b_`) without relying on
 * Prometheus relabeling.
 */
export interface GatewayMetricsConfig {
  enabled?: boolean;
  prefix?: string;
  /**
   * Phase 3b post-review: optional bearer token. When set, /metrics
   * requires `Authorization: Bearer <token>`. Wins over the IP
   * allowlist.
   */
  bearerToken?: string;
  /**
   * Client-IP allowlist applied when no bearer is set. Default is
   * localhost-only (`['127.0.0.1', '::1', 'localhost']`). Include
   * `0.0.0.0` to disable IP checks entirely.
   */
  allowedHosts?: string[];
}

export interface GatewayConfig {
  /** Master switch. False (default) = even AAO_MODE=gateway refuses to start. */
  enabled: boolean;
  /** Bind port for the gateway HTTP server. */
  listenPort: number;
  /** Overall request budget incl. streaming body. Default 600s. */
  requestTimeoutSec: number;
  /** Per upstream fetch timeout (also used as idle-chunk timeout floor). Default 30s. */
  upstreamTimeoutSec: number;
  /**
   * Phase 3a F8: how long the gateway waits after SIGTERM for in-flight
   * SSE streams to drain before force-closing. Each registered stream
   * gets an abort + `gateway_shutdown` SSE event at signal time; this
   * is the budget for them to finish their current chunk and end
   * cleanly. Default 30s — well below systemd's 90s TimeoutStopSec and
   * generous enough for short responses to finish, but bounded enough
   * to avoid SIGKILL on busy nodes.
   */
  shutdownGracefulSec: number;
  backends: GatewayBackendConfig[];
  virtualKeys: GatewayVirtualKey[];
  registry?: GatewayRegistryConfig;
  /** Phase 3b: Prometheus exporter settings. Defaults to enabled. */
  metrics?: GatewayMetricsConfig;
}

export const DEFAULT_GATEWAY_LISTEN_PORT = 4000;
export const DEFAULT_GATEWAY_REQUEST_TIMEOUT_SEC = 600;
export const DEFAULT_GATEWAY_UPSTREAM_TIMEOUT_SEC = 30;
export const DEFAULT_GATEWAY_SHUTDOWN_GRACEFUL_SEC = 30;

/**
 * Normalize a partial gateway config to a fully-populated GatewayConfig.
 * Missing fields fall back to documented defaults; arrays default to [].
 *
 * Returning a fully-defaulted config (rather than `Partial<GatewayConfig>`)
 * means callers never have to check `?? default` at use site — typos in
 * field names surface as `undefined` at the loader, not as silent fallbacks
 * at the router.
 */
export function normalizeGatewayConfig(raw: Partial<GatewayConfig> | undefined): GatewayConfig {
  const r = raw ?? {};
  return {
    enabled: r.enabled === true,
    listenPort: typeof r.listenPort === 'number' && Number.isFinite(r.listenPort)
      ? r.listenPort
      : DEFAULT_GATEWAY_LISTEN_PORT,
    requestTimeoutSec: typeof r.requestTimeoutSec === 'number' && Number.isFinite(r.requestTimeoutSec)
      ? r.requestTimeoutSec
      : DEFAULT_GATEWAY_REQUEST_TIMEOUT_SEC,
    upstreamTimeoutSec: typeof r.upstreamTimeoutSec === 'number' && Number.isFinite(r.upstreamTimeoutSec)
      ? r.upstreamTimeoutSec
      : DEFAULT_GATEWAY_UPSTREAM_TIMEOUT_SEC,
    shutdownGracefulSec:
      typeof r.shutdownGracefulSec === 'number' && Number.isFinite(r.shutdownGracefulSec) && r.shutdownGracefulSec > 0
        ? r.shutdownGracefulSec
        : DEFAULT_GATEWAY_SHUTDOWN_GRACEFUL_SEC,
    backends: Array.isArray(r.backends) ? r.backends.map(normalizeBackend) : [],
    virtualKeys: Array.isArray(r.virtualKeys) ? r.virtualKeys.map(normalizeKey) : [],
    registry: r.registry,
    metrics: normalizeMetrics(r.metrics),
  };
}

/**
 * Normalize the metrics block. Missing block / fields default to
 * enabled=true + prefix=aao_gateway. We coerce the prefix through the
 * validator so a typo'd prefix surfaces at validateGatewayConfig
 * rather than after a metric collision at runtime.
 */
function normalizeMetrics(raw: GatewayMetricsConfig | undefined): GatewayMetricsConfig {
  const allowEnv = process.env['AAO_GATEWAY_METRICS_ALLOWED_HOSTS'];
  const tokenEnv = process.env['AAO_GATEWAY_METRICS_BEARER_TOKEN'];
  const allowedHosts = Array.isArray(raw?.allowedHosts)
    ? raw!.allowedHosts.filter(v => typeof v === 'string' && v.length > 0)
    : undefined;
  const envAllowedHosts = allowEnv
    ? allowEnv.split(',').map(s => s.trim()).filter(s => s.length > 0)
    : undefined;
  return {
    enabled: raw?.enabled !== false, // default true; only explicit false disables
    prefix: typeof raw?.prefix === 'string' && raw.prefix.length > 0 ? raw.prefix : 'aao_gateway',
    bearerToken:
      typeof tokenEnv === 'string' && tokenEnv.length > 0
        ? tokenEnv
        : typeof raw?.bearerToken === 'string' && raw.bearerToken.length > 0
          ? raw.bearerToken
          : undefined,
    allowedHosts:
      envAllowedHosts && envAllowedHosts.length > 0
        ? envAllowedHosts
        : allowedHosts && allowedHosts.length > 0
          ? allowedHosts
          : undefined,
  };
}

function normalizeBackend(b: GatewayBackendConfig): GatewayBackendConfig {
  // Don't silently coerce missing / invalid max_slots to 1. A typo like
  // `max_slot: 4` would previously land as maxSlots=1 with no error,
  // throttling that backend to 1 concurrent request forever. Surface
  // NaN here and let validateGatewayConfig flag it loudly at startup.
  const ms = b.maxSlots;
  const maxSlots =
    typeof ms === 'number' && Number.isFinite(ms) && ms > 0
      ? Math.floor(ms)
      : NaN;
  return {
    id: b.id,
    endpoint: b.endpoint,
    model: b.model,
    maxSlots,
    apiKey: b.apiKey,
  };
}

function normalizeKey(k: GatewayVirtualKey): GatewayVirtualKey {
  // Phase 2b: coerce malformed budget / rate values to undefined. We
  // never persist 0/negative because the DB normalizer treats those as
  // null (= unlimited) — that would silently drop the operator's
  // attempted lockdown intent. Better to surface validation errors at
  // boot.
  const tokensBudget =
    typeof k.tokensBudget === 'number' && Number.isFinite(k.tokensBudget) && k.tokensBudget > 0
      ? Math.floor(k.tokensBudget)
      : undefined;
  const rateLimitRpm =
    typeof k.rateLimitRpm === 'number' && Number.isFinite(k.rateLimitRpm) && k.rateLimitRpm > 0
      ? Math.floor(k.rateLimitRpm)
      : undefined;
  return {
    key: k.key,
    team: k.team,
    allowedModels: Array.isArray(k.allowedModels) ? k.allowedModels.slice() : undefined,
    tokensBudget,
    rateLimitRpm,
  };
}

/**
 * Validate gateway config. Returns a list of human-readable errors — empty
 * list means OK. Mirrors the validateConfig() pattern in src/config.ts so
 * the same caller can collect both worker- and gateway-mode errors.
 */
export function validateGatewayConfig(cfg: GatewayConfig): string[] {
  const errors: string[] = [];

  if (!Number.isInteger(cfg.listenPort) || cfg.listenPort <= 0 || cfg.listenPort > 65535) {
    errors.push(`gateway.listen_port must be an integer in [1, 65535], got ${cfg.listenPort}`);
  }
  if (typeof cfg.requestTimeoutSec !== 'number' || cfg.requestTimeoutSec <= 0) {
    errors.push('gateway.request_timeout_sec must be positive');
  }
  if (typeof cfg.upstreamTimeoutSec !== 'number' || cfg.upstreamTimeoutSec <= 0) {
    errors.push('gateway.upstream_timeout_sec must be positive');
  }
  if (typeof cfg.shutdownGracefulSec !== 'number' || cfg.shutdownGracefulSec <= 0) {
    errors.push('gateway.shutdown_graceful_sec must be positive');
  }

  if (!Array.isArray(cfg.backends) || cfg.backends.length === 0) {
    errors.push('gateway.backends must be a non-empty array');
  } else {
    const seenIds = new Set<string>();
    for (const b of cfg.backends) {
      if (!b.id || typeof b.id !== 'string' || b.id.trim() === '') {
        errors.push('gateway.backends[*].id must be a non-empty string');
        continue;
      }
      if (seenIds.has(b.id)) {
        errors.push(`gateway.backends: duplicate id '${b.id}'`);
      }
      seenIds.add(b.id);
      if (!b.endpoint || typeof b.endpoint !== 'string') {
        errors.push(`gateway.backends[${b.id}].endpoint must be a non-empty string`);
      } else {
        // Lightweight URL check + scheme allowlist. Anything outside
        // http/https risks SSRF (file://, data:, gopher:// etc.) — same
        // hardening pattern as web-fetch / DownloadFile.
        try {
          const u = new URL(b.endpoint);
          if (u.protocol !== 'http:' && u.protocol !== 'https:') {
            errors.push(
              `gateway.backends[${b.id}].endpoint must use http: or https: scheme (got '${u.protocol}')`,
            );
          }
        } catch {
          errors.push(`gateway.backends[${b.id}].endpoint is not a valid URL: ${b.endpoint}`);
        }
      }
      if (!b.model || typeof b.model !== 'string') {
        errors.push(`gateway.backends[${b.id}].model must be a non-empty string`);
      }
      if (!Number.isInteger(b.maxSlots) || b.maxSlots <= 0) {
        errors.push(
          `gateway.backends[${b.id}].max_slots must be a positive integer (got ${b.maxSlots})`,
        );
      }
    }
  }

  if (Array.isArray(cfg.virtualKeys)) {
    const seenKeys = new Set<string>();
    for (const k of cfg.virtualKeys) {
      if (!k.key || typeof k.key !== 'string' || k.key.trim() === '') {
        errors.push('gateway.virtual_keys[*].key must be a non-empty string');
        continue;
      }
      if (seenKeys.has(k.key)) {
        // Duplicate keys are a serious config error: the second entry's
        // team / allowedModels would silently never match because the
        // first match wins. Fail loud at startup instead.
        errors.push(`gateway.virtual_keys: duplicate key (team=${k.team})`);
      }
      seenKeys.add(k.key);
      if (!k.team || typeof k.team !== 'string') {
        errors.push('gateway.virtual_keys[*].team must be a non-empty string');
      }
      if (k.allowedModels !== undefined && !Array.isArray(k.allowedModels)) {
        errors.push(`gateway.virtual_keys[team=${k.team}].allowed_models must be an array if defined`);
      }
      if (k.tokensBudget !== undefined && (typeof k.tokensBudget !== 'number' || k.tokensBudget <= 0)) {
        errors.push(
          `gateway.virtual_keys[team=${k.team}].tokens_budget must be a positive integer if defined`,
        );
      }
      if (k.rateLimitRpm !== undefined && (typeof k.rateLimitRpm !== 'number' || k.rateLimitRpm <= 0)) {
        errors.push(
          `gateway.virtual_keys[team=${k.team}].rate_limit_rpm must be a positive integer if defined`,
        );
      }
    }
  } else if (cfg.virtualKeys !== undefined) {
    errors.push('gateway.virtual_keys must be an array');
  }

  if (cfg.metrics?.prefix !== undefined) {
    const p = cfg.metrics.prefix;
    if (typeof p !== 'string' || !/^[a-z][a-z0-9_]*$/.test(p)) {
      errors.push(
        `gateway.metrics.prefix must match /^[a-z][a-z0-9_]*$/ (got ${JSON.stringify(p)})`,
      );
    } else if (p.length < 2 || p.length > 64) {
      // Cap prefix length. The regex alone accepts 1 char or a giant
      // string — both create dashboard pain.
      errors.push(
        `gateway.metrics.prefix length must be 2-64 chars (got ${p.length})`,
      );
    }
  }

  if (cfg.metrics?.bearerToken !== undefined) {
    const t = cfg.metrics.bearerToken;
    if (typeof t !== 'string' || t.length === 0) {
      errors.push('gateway.metrics.bearer_token must be a non-empty string if defined');
    }
  }

  if (cfg.metrics?.allowedHosts !== undefined) {
    const h = cfg.metrics.allowedHosts;
    if (!Array.isArray(h) || h.some(v => typeof v !== 'string' || v.length === 0)) {
      errors.push('gateway.metrics.allowed_hosts must be an array of non-empty strings');
    }
  }

  return errors;
}

/**
 * Pull the (possibly missing) gateway block off AppConfig. Used by
 * gateway/bootstrap.ts so both worker and gateway modes can call
 * `new ConfigManager(...)` and only the gateway path consumes this slice.
 */
export function readGatewayConfig(app: AppConfig): GatewayConfig {
  // The block isn't part of AppConfig's static type (worker mode never
  // touches it) so we read it through a record cast.
  const raw = (app as unknown as Record<string, unknown>)['gateway'] as Partial<GatewayConfig> | undefined;
  return normalizeGatewayConfig(raw);
}
