/**
 * Least-busy backend router.
 *
 * Selection algorithm:
 *  1. Filter to backends whose configured `id` OR `model` strictly
 *     matches `request.model`. Accepting both means a client can
 *     either name a specific deployment (`b.id`, mirrors LiteLLM's
 *     deployment-name convention used in our /v1/models + /health
 *     responses) or fall back to the generic model alias (`b.model`,
 *     for tools that pass through the raw model name configured on
 *     the worker side). Without the `b.id` arm, the gateway exposed
 *     ids via /v1/models that the worker would faithfully echo back
 *     in the chat request, and routing would 503 because nothing
 *     matched on b.model. Discovered during 2026-05-20 dogfooding.
 *  2. Drop backends the registry says are offline or saturated
 *     (`busy_slots >= total_slots`).
 *  3. Sort the survivors by `busy_slots / total_slots` ascending —
 *     the most idle backend wins.
 *  4. Break ties with a process-local round-robin counter so two
 *     equally-idle backends actually share traffic rather than the
 *     first-by-id getting everything.
 *
 * Returns null when no candidate survives — the handler then emits
 * 503 `{error: "no available backend"}` so the client can retry.
 *
 * Why a round-robin counter and not random: deterministic in tests and
 * predictable in logs. The bias is at most O(slots) over the lifetime
 * of one process, which is fine for a single binary.
 */
import type { GatewayBackendConfig } from './config.js';
import type { BackendStatusRegistry, NodeStatus } from '../engine/backend-status-registry.js';

export interface RouterSnapshotBackend {
  config: GatewayBackendConfig;
  status: NodeStatus | null;
}

export interface Router {
  /**
   * Pick a backend for the requested model. Returns null when no
   * backend matches the model OR all matching backends are
   * offline/saturated.
   */
  pick(model: string): GatewayBackendConfig | null;
  /**
   * Inspection helper — returns the registry-augmented view of every
   * configured backend (regardless of model). Useful for /v1/models
   * and /health response building.
   */
  snapshot(): RouterSnapshotBackend[];
}

/**
 * Minimal view of the gateway's BackendInflightCounter that the router
 * needs. Kept as a local structural type (not an import of
 * BackendInflightCounter from stream-proxy) to avoid a module cycle —
 * stream-proxy already imports the Router type from here.
 */
export interface RouterInflightView {
  /** Current live reservation/in-flight count for a backend id. */
  get(backendId: string): number;
  /** Reserve one slot on a backend id. */
  inc(backendId: string): void;
}

export interface CreateRouterDeps {
  /** Live list of configured backends. */
  getBackends: () => GatewayBackendConfig[];
  /** Registry that polls per-backend busy slots. */
  registry: Pick<BackendStatusRegistry, 'getAll' | 'noteSubscriberActivity'>;
  /**
   * Live per-backend in-flight counter (the SAME instance stream-proxy
   * and /health use). When provided, pick() does two things the stale
   * probe alone cannot:
   *
   *  1. Saturation is judged on `max(probed busySlots, live reservations)`
   *     instead of the 5–30s-stale probe. This mirrors health-endpoint's
   *     reconciliation (max, not sum, so we don't double-count our own
   *     in-flight requests the probe already sees).
   *  2. The chosen backend is RESERVED (`inflight.inc(id)`) atomically
   *     with selection, so the very next pick() — even one microsecond
   *     later, inside the same probe interval — already sees this request.
   *
   * Together these enforce the per-host `max_slots` cap in real time and
   * close the over-admission window where a burst of requests all read
   * the same stale busySlots=0 and stacked onto one host. The matching
   * release (`dec`) is owned by stream-proxy's `decInflightOnce()` finally
   * block, which runs in every terminal path.
   *
   * Optional: unit callers that don't exercise concurrency may omit it,
   * in which case pick() falls back to the probe-only behaviour.
   */
  inflight?: RouterInflightView;
}

interface RankedCandidate {
  backend: GatewayBackendConfig;
  ratio: number;
}

export function createRouter(deps: CreateRouterDeps): Router {
  // Per-model round-robin cursor so ties across models don't share state.
  // A simple Map(model, counter) is enough for Phase 1 — Phase 2 might
  // care about per-team weighting.
  const rr = new Map<string, number>();

  // Build a nodeId→status lookup map from a single getAll() snapshot.
  // Used by both pick() (per-request, O(N) total instead of O(N²) from
  // calling getAll() per backend) and snapshot().
  function buildStatusIndex(): Map<string, NodeStatus> {
    const all = deps.registry.getAll();
    const m = new Map<string, NodeStatus>();
    for (const s of all) m.set(s.nodeId, s);
    return m;
  }

  return {
    pick(model: string): GatewayBackendConfig | null {
      // Note registry activity so the polling cadence tightens while
      // we're actively routing. Without this, a single-page burst of
      // requests would still see the idle 30s cadence.
      deps.registry.noteSubscriberActivity?.();

      if (typeof model !== 'string' || model.length === 0) return null;
      const backends = deps.getBackends();
      // One getAll() per pick, not one per backend — the previous
      // `lookupStatus(b.id)` inside the loop did `registry.getAll()`
      // (which slices its internal cache) once per candidate. With N
      // backends that's N getAll() calls + N linear scans = O(N²).
      const byId = buildStatusIndex();
      // Two-tier candidate pool. Warm = has a probe row and is alive
      // with free capacity. Cold = no probe row yet (boot warm-up
      // window). Cold backends are only considered if there are no
      // warm ones — otherwise cold (ratio 0) would always beat warm
      // (ratio 0.25+) and skew the first 5s of traffic away from
      // backends that already have known headroom.
      const warm: RankedCandidate[] = [];
      const cold: RankedCandidate[] = [];
      for (const b of backends) {
        // Match either the deployment id (LiteLLM-style routing key
        // mirrored in /v1/models + /health) or the model alias the
        // backend serves. See top-of-file docstring for why both arms
        // are needed.
        if (b.id !== model && b.model !== model) continue;
        // Live reservation count for this backend (0 when no inflight
        // counter is wired). Used both to gate admission and to rank.
        const reserved = deps.inflight?.get(b.id) ?? 0;
        const st = byId.get(b.id) ?? null;
        if (!st) {
          // Unprobed (boot warm-up) → cold fallback only. The probe row
          // doesn't exist yet, so the live reservation count against the
          // configured max_slots is the ONLY cap that can hold here —
          // without it, the warm-up window over-admits past max_slots.
          const total = b.maxSlots;
          if (total <= 0) continue;
          if (reserved >= total) continue;
          cold.push({ backend: b, ratio: reserved / total });
          continue;
        }
        if (st.online === false) continue;
        const total = st.totalSlots > 0 ? st.totalSlots : b.maxSlots;
        if (total <= 0) continue;
        // Effective busy = the higher of the stale probe and the live
        // reservation count. The probe lags 5–30s; reservations are exact
        // for requests this gateway dispatched. max() (not sum) mirrors
        // health-endpoint and avoids double-counting our own in-flight
        // requests that the probe already reflects.
        const effectiveBusy = Math.max(st.busySlots, reserved);
        if (effectiveBusy >= total) continue;
        warm.push({ backend: b, ratio: effectiveBusy / total });
      }
      const candidates = warm.length > 0 ? warm : cold;
      if (candidates.length === 0) return null;

      // Stable ascending sort by ratio.
      candidates.sort((a, b) => a.ratio - b.ratio);
      const bestRatio = candidates[0]!.ratio;
      // Round-robin only across ties at the minimum ratio.
      const tied = candidates.filter(c => c.ratio === bestRatio);
      let winner: GatewayBackendConfig;
      if (tied.length === 1) {
        winner = tied[0]!.backend;
      } else {
        const cursor = (rr.get(model) ?? 0) % tied.length;
        rr.set(model, cursor + 1);
        winner = tied[cursor]!.backend;
      }
      // Reserve the slot atomically with selection so the next pick() in
      // the same probe interval already counts this request. Released by
      // stream-proxy's decInflightOnce() in finally. No-op when no
      // inflight counter is wired (probe-only fallback).
      deps.inflight?.inc(winner.id);
      return winner;
    },
    snapshot(): RouterSnapshotBackend[] {
      const byId = buildStatusIndex();
      return deps.getBackends().map(config => ({
        config,
        status: byId.get(config.id) ?? null,
      }));
    },
  };
}
