/**
 * Phase 3c — same-process gateway mount.
 *
 * Mounts the AAO Gateway's Express sub-app on the worker bridge so a
 * single AAO process can serve both the worker UI (`/api/local/*` etc.)
 * and the gateway endpoints (`/v1/*`, `/health`) on the same port.
 *
 * Path scoping
 * ────────────
 * The gateway owns these paths:
 *   - POST /v1/chat/completions
 *   - GET  /v1/models
 *   - GET  /health, /health/liveness
 * No conflict with the worker bridge's `/api/*` / `/ui/*` / `/auth/*`
 * roots. `/metrics` deliberately stays with the worker — gateway counters
 * are registered into the worker's prom-client registry so one scrape
 * endpoint serves both label spaces (no port conflict).
 *
 * Dynamic enable / disable
 * ────────────────────────
 * We mount a small "gate" middleware ahead of the gateway sub-app that
 * returns 404 for any gateway path while `gateway.enabled !== true`,
 * matching the behaviour an operator sees when the gateway block is
 * absent. Flipping the flag at runtime (via ConfigManager
 * `config-changed`) requires no Express remount — the routes are
 * always registered, the gate just stops short-circuiting them.
 *
 * Lifecycle
 * ─────────
 *   false -> true: createSharedGatewayDependencies + start + flip gate open
 *   true  -> false: flip gate closed + shared.stop() (drains in-flight
 *     SSE via streamRegistry.signalShutdown)
 *
 * Hot edits to backends / virtual_keys are picked up by the gateway
 * automatically because createSharedGatewayDependencies reads them
 * through closures over the latest GatewayConfig snapshot at start
 * time. A backend list edit currently requires an enable -> disable ->
 * enable cycle to take effect; full hot-reload of backends without a
 * disable is Phase 4 scope.
 */
import { type Express, type Request, type Response, type NextFunction } from 'express';
import { logger } from '../logger.js';
import type { ConfigManager } from '../config-manager.js';
import type { Repository } from '../db/repository.js';
import {
  createBackendStatusRegistry,
  type BackendStatusRegistry,
} from '../engine/backend-status-registry.js';
import { buildDirectProbe } from '../engine/backend-probes.js';
import type { Registry as PromRegistry } from 'prom-client';
import {
  readGatewayConfig,
  validateGatewayConfig,
  type GatewayConfig,
} from '../gateway/config.js';
import { createGatewayApp } from '../gateway/server.js';
import {
  createSharedGatewayDependencies,
  type SharedGatewayDependencies,
} from '../gateway/shared-dependencies.js';
import { buildWorkerDefsFromBackends } from '../gateway/bootstrap.js';

/**
 * Path roots the gateway sub-app exclusively owns in same-process mode.
 * These are gated by the 404 middleware while the gateway is not
 * running — clients see the same JSON the not-found handler in
 * gateway/server.ts emits.
 *
 * `/health/liveness` is also gateway-only (more specific path stays
 * with the gateway's k8s liveness probe).
 */
const GATEWAY_ONLY_PREFIXES = ['/v1/'];

/**
 * Classification of a request path's relationship to the gateway:
 *
 * - `'gateway-only'`: the gateway exclusively owns this path. When
 *   the gateway isn't running, the gate returns 404 to mimic the
 *   "block absent" behaviour an operator sees in disabled mode.
 *   Examples: `/v1/*`, `/health/liveness`.
 *
 * - `'gateway-when-enabled'`: gateway can answer this path when
 *   running, but the bridge has its own handler that must respond
 *   when the gateway is off. The gate FORWARDS to gateway while
 *   running, FALLS THROUGH otherwise.
 *   Examples: `/health` (LiteLLM-compat JSON when on, bridge ok JSON
 *   when off — CRITICAL-3 fix).
 *
 * - `false`: path is not gateway-related; gate never touches it.
 */
export type GatewayPathKind = 'gateway-only' | 'gateway-when-enabled' | false;

/**
 * Classify a path against the gateway URL surface. Pure function —
 * exported so the bridge / tests can reuse the same logic that the
 * gate middleware uses.
 */
export function classifyGatewayPath(path: string): GatewayPathKind {
  // A1: handle both /v1 (no trailing slash) and /v1/* consistently.
  // Previously `/v1/` (exact) was missing from the prefix match and
  // bare `/v1` silently bypassed the gate.
  if (path === '/v1' || path === '/v1/') return 'gateway-only';
  for (const prefix of GATEWAY_ONLY_PREFIXES) {
    if (path.startsWith(prefix)) return 'gateway-only';
  }
  if (path === '/health/liveness') return 'gateway-only';
  if (path === '/health') return 'gateway-when-enabled';
  return false;
}

/**
 * Back-compat boolean classifier kept for callers that don't care
 * about the gateway-when-enabled tri-state. Returns true ONLY for
 * gateway-only paths (NOT gateway-when-enabled) so existing
 * test asserts like `isGatewayPath('/health') === false` still hold.
 */
export function isGatewayPath(path: string): boolean {
  return classifyGatewayPath(path) === 'gateway-only';
}

export type GatewayMountState =
  | 'disabled'        // config says enabled !== true
  | 'starting'        // shared.start() in progress
  | 'running'         // gate open, deps live
  | 'stopping'        // shared.stop() in progress (drain)
  | 'misconfigured';  // enabled === true but validateGatewayConfig found errors

export interface GatewayMountHandle {
  /** Current state. Reflects the in-memory flag, not config on disk. */
  getState(): GatewayMountState;
  /** Validation errors from the most-recent attempt (or empty). */
  getErrors(): string[];
  /**
   * Apply a new config snapshot. The bridge calls this from a
   * `ConfigManager#onConfigChanged` listener; tests can drive it
   * directly. Returns true when the call resulted in a state change.
   */
  applyConfig(next: GatewayConfig): Promise<boolean>;
  /**
   * Forcibly stop the gateway (used by graceful shutdown). Safe to call
   * when already stopped.
   */
  stop(): Promise<void>;
}

export interface MountGatewayOptions {
  app: Express;
  configManager: ConfigManager;
  repo: Repository | null;
  /**
   * DEPRECATED — kept for source compatibility but no longer used.
   * The gateway now owns its own BackendStatusRegistry over
   * `gateway.backends[]` (CRITICAL-2 fix). Passing the worker bridge's
   * registry here is silently ignored — the worker registry probes
   * `provider.workers[]`, whose ids do NOT match gateway.backends[].id,
   * which used to make `/health` empty + least-busy routing blind.
   * Remove this field in Phase 4 once the bridge call site is updated.
   */
  backendStatusRegistry?: BackendStatusRegistry;
  /** Shared with worker /metrics. Null disables gateway metrics. */
  promRegistry: PromRegistry | null;
  /** Prefix for gateway counters inside the shared registry. */
  metricsPrefix?: string;
  /** Test hook — defaults to globalThis.fetch. */
  fetchImpl?: typeof fetch;
  /**
   * Test hook for the per-gateway BackendStatusRegistry. Defaults to
   * the real `createBackendStatusRegistry` over `gateway.backends`. Tests
   * can substitute a fake to drive snapshots deterministically without
   * spinning up real probes.
   */
  buildRegistry?: (config: GatewayConfig) => BackendStatusRegistry;
}

/**
 * Mount the gateway sub-app on `app` and return a handle the caller
 * uses to wire the ConfigManager event subscription. The gateway is
 * NOT started here — call `applyConfig()` with the current config
 * snapshot from the bridge to bring it up if `enabled: true`.
 */
export function mountGateway(opts: MountGatewayOptions): GatewayMountHandle {
  const { app, configManager, repo, promRegistry } = opts;
  const metricsPrefix = opts.metricsPrefix ?? 'aao_gateway';
  const buildRegistry =
    opts.buildRegistry ??
    ((config: GatewayConfig): BackendStatusRegistry =>
      createBackendStatusRegistry({
        // Per CRITICAL-2: the gateway needs a registry keyed by
        // gateway.backends[].id, not the worker bridge's
        // provider.workers[].id. Same-host double-probe with the
        // worker registry is acceptable (probes are cheap) and the
        // two registries' lifetimes are now independent.
        getWorkers: () => buildWorkerDefsFromBackends(config.backends),
        probeDirect: buildDirectProbe(),
        // Gateway backends are never proxy=true (LiteLLM is what we're
        // replacing) — supply a stub so the registry contract holds.
        probeProxy: async () => [],
      }));

  let state: GatewayMountState = 'disabled';
  let lastErrors: string[] = [];
  let shared: SharedGatewayDependencies | null = null;
  let gatewaySubApp: Express | null = null;
  let activeConfig: GatewayConfig | null = null;
  // Per-gateway registry over gateway.backends — owned + stopped here,
  // independent of any worker registry the bridge separately keeps.
  let ownedRegistry: BackendStatusRegistry | null = null;
  // F1: pending config replay. applyConfig() during 'starting' /
  // 'stopping' (an in-flight transition still draining through the
  // mutex) stores the latest intent here so the next mutex turn can
  // replay it instead of dropping it.
  let pendingConfig: GatewayConfig | null = null;
  // Serialize start/stop transitions so a rapid toggle can't interleave.
  let mutex: Promise<void> = Promise.resolve();

  // --- 404 gate -------------------------------------------------------
  // Mounted exactly once. Returns 404 for gateway-only paths while
  // state != 'running'. For 'gateway-when-enabled' paths (e.g.
  // `/health`) we FALL THROUGH so the bridge's own handler can answer
  // when the gateway is off. Non-gateway paths fall through
  // unconditionally. We must register this BEFORE the sub-app
  // dispatch middleware so the gate can short-circuit before sub-app
  // routing.
  app.use((req: Request, res: Response, next: NextFunction) => {
    const kind = classifyGatewayPath(req.path);
    if (!kind) return next();
    if (state === 'running' && gatewaySubApp) return next();
    if (kind === 'gateway-when-enabled') return next(); // bridge owns when off
    // gateway-only and gateway not running → 404 (mimic gateway not-found
    // body shape so clients see the same JSON whether the gateway is
    // off or the path is genuinely missing).
    res.status(404).json({ error: `not found: ${req.method} ${req.path}` });
  });

  // Sub-app dispatch middleware. Permanently registered; when
  // `gatewaySubApp` is null (gateway never enabled this process) it
  // forwards. When set + running, it routes through the gateway's
  // Express app for any gateway-classified path — including
  // 'gateway-when-enabled' so /health returns the LiteLLM-shape JSON
  // (CRITICAL-3 fix).
  app.use((req, res, next) => {
    if (!gatewaySubApp) return next();
    if (state !== 'running') return next();
    if (classifyGatewayPath(req.path) === false) return next();
    gatewaySubApp(req, res, next);
  });

  const startGateway = async (config: GatewayConfig): Promise<void> => {
    const errors = validateGatewayConfig(config);
    if (errors.length > 0) {
      lastErrors = errors;
      state = 'misconfigured';
      for (const e of errors) {
        logger.warn(`[bridge-gateway] config error blocking start: ${e}`);
      }
      return;
    }
    state = 'starting';
    lastErrors = [];
    // Build + start the per-gateway BackendStatusRegistry first so the
    // gateway router sees backend ids from gateway.backends (not the
    // worker bridge's provider.workers list, which would 404-on-status
    // for the gateway's ids — CRITICAL-2).
    ownedRegistry = buildRegistry(config);
    ownedRegistry.start();
    shared = createSharedGatewayDependencies({
      config,
      registry: ownedRegistry,
      repo,
      promRegistry,
      prefix: metricsPrefix,
    });
    shared.start();
    const { app: subApp } = createGatewayApp({
      config,
      registry: ownedRegistry,
      fetchImpl: opts.fetchImpl,
      dbLookup: shared.dbLookup,
      touchLastUsed: shared.touchLastUsed,
      postAuthMiddleware: shared.postAuthMiddleware,
      usageRecorder: shared.usageRecorder ?? undefined,
      streamRegistry: shared.streamRegistry,
      inflight: shared.inflight,
      metrics: shared.metrics ?? undefined,
      // Phase 3c: same-process mode does NOT mount /metrics from the
      // gateway side — gateway counters land in the shared worker
      // registry that already serves /metrics. Pass undefined here so
      // createGatewayApp skips the metrics endpoint mount.
      metricsRegistry: undefined,
    });
    gatewaySubApp = subApp;
    activeConfig = config;
    state = 'running';
    logger.info(
      `[bridge-gateway] gateway enabled (same-process) backends=${config.backends.length} virtual_keys=${config.virtualKeys.length}`,
    );
  };

  const stopGateway = async (): Promise<void> => {
    if (!shared) return;
    state = 'stopping';
    try {
      await shared.stop();
    } catch (e) {
      logger.warn(`[bridge-gateway] shared.stop threw: ${e instanceof Error ? e.message : String(e)}`);
    }
    // Tear down the per-gateway registry's probe loop — independent of
    // any worker registry the bridge keeps running.
    if (ownedRegistry) {
      try {
        await ownedRegistry.stop();
      } catch (e) {
        logger.warn(
          `[bridge-gateway] ownedRegistry.stop threw: ${e instanceof Error ? e.message : String(e)}`,
        );
      }
      ownedRegistry = null;
    }
    shared = null;
    gatewaySubApp = null;
    activeConfig = null;
    state = 'disabled';
    logger.info('[bridge-gateway] gateway disabled');
  };

  const transition = async (next: GatewayConfig): Promise<boolean> => {
    const prevState = state;
    if (next.enabled !== true) {
      if (state === 'running' || state === 'misconfigured') {
        await stopGateway();
        // F2: after stopGateway() for the misconfigured branch (where
        // shared was never assigned) the early return inside
        // stopGateway leaves `state` at 'misconfigured'. Force the
        // disable + clear errors here so applyConfig({enabled:false})
        // unconditionally leaves the handle in a clean state — the
        // status endpoint should never see a misconfigured handle
        // after the operator turned the gateway off.
        state = 'disabled';
        lastErrors = [];
        return true;
      }
      // F1: starting / stopping mid-transition — record the latest
      // intent so the in-flight transition can replay it via the
      // mutex chain instead of dropping it on the floor.
      // NOTE: defensive-only. Under the current mutex chain
      // (`applyConfig = mutex.then(...)`), transition() runs only after
      // the prior start/stopGateway settles, so `state` is never
      // observed as 'starting' / 'stopping' here. Kept to survive
      // future refactors that allow concurrent transition() calls.
      if (state === 'starting' || state === 'stopping') {
        pendingConfig = next;
        return false;
      }
      // already disabled — clear any stale validation errors
      if (lastErrors.length > 0) {
        lastErrors = [];
        return true;
      }
      return false;
    }
    // next.enabled === true
    if (state === 'disabled' || state === 'misconfigured') {
      await startGateway(next);
      return prevState !== state;
    }
    // F1: enable while another transition is in flight → queue the
    // config and let the mutex finalizer replay it.
    // NOTE: defensive-only — see the matching branch above. The mutex
    // chain already serialises transition() calls, so this is dead
    // under the current model but cheap insurance for future refactors.
    if (state === 'starting' || state === 'stopping') {
      pendingConfig = next;
      return false;
    }
    // Already running. Backends / keys list changed? Bounce so the
    // shared deps pick up the new snapshot. Full in-place hot reload
    // is Phase 4 scope.
    if (state === 'running' && activeConfig && !configsEquivalent(activeConfig, next)) {
      logger.info('[bridge-gateway] gateway config changed; bouncing same-process mount');
      await stopGateway();
      await startGateway(next);
      return true;
    }
    return false;
  };

  /**
   * Mutex-chained applyConfig that drains the F1 pending queue. After
   * the current transition settles, replay any pendingConfig stored
   * during 'starting' / 'stopping' so configs that arrived
   * mid-transition aren't dropped. Returning the OUTER promise (not
   * the inner replay) preserves the public contract: callers see their
   * own transition's settlement, the replay's result lands on
   * whatever listener cares.
   */
  const applyConfigInternal = async (next: GatewayConfig): Promise<boolean> => {
    const changed = await transition(next);
    if (pendingConfig) {
      const replay = pendingConfig;
      pendingConfig = null;
      const drain = mutex.then(() => applyConfigInternal(replay));
      mutex = drain.then(() => undefined, () => undefined);
      drain.catch((e) => {
        logger.warn(
          `[bridge-gateway] pending config replay threw: ${e instanceof Error ? e.message : String(e)}`,
        );
      });
    }
    return changed;
  };

  const handle: GatewayMountHandle = {
    getState: () => state,
    getErrors: () => lastErrors.slice(),
    applyConfig: (next) => {
      // Serialize through the mutex to avoid interleaved start/stop.
      const run = mutex.then(() => applyConfigInternal(next));
      mutex = run.then(() => undefined, () => undefined);
      return run;
    },
    stop: async () => {
      const run = mutex.then(async () => {
        await stopGateway();
        // F2: stop() must always land in a clean 'disabled' state with
        // no stale validation errors. stopGateway() early-returns when
        // shared is null (e.g. handle parked in 'misconfigured' state
        // before any successful start), leaving state untouched. Force
        // the cleanup here.
        state = 'disabled';
        lastErrors = [];
      });
      mutex = run.then(() => undefined, () => undefined);
      await run;
    },
  };

  // Subscribe to config changes so an admin enabling the gateway from
  // Settings UI takes effect without a server restart.
  configManager.onConfigChanged((cfg) => {
    const next = readGatewayConfig(cfg);
    handle.applyConfig(next).catch((e) => {
      logger.warn(`[bridge-gateway] applyConfig from config-changed threw: ${e instanceof Error ? e.message : String(e)}`);
    });
  });

  return handle;
}

/**
 * Stable JSON.stringify that sorts object keys recursively so two
 * objects whose YAML round-trip produced different key orders still
 * compare equal. Plain JSON.stringify is key-order-sensitive — when
 * the bridge re-serialises config.yaml the key order can flip and a
 * spurious bounce would fire on an otherwise no-op save.
 *
 * Arrays preserve their order (intentional — `backends[]` /
 * `virtualKeys[]` ordering is semantic from the operator's
 * perspective; first match wins in router lookups).
 */
function stableStringify(value: unknown): string {
  return JSON.stringify(value, (_key, val) => {
    if (val !== null && typeof val === 'object' && !Array.isArray(val)) {
      const sorted: Record<string, unknown> = {};
      for (const k of Object.keys(val as Record<string, unknown>).sort()) {
        sorted[k] = (val as Record<string, unknown>)[k];
      }
      return sorted;
    }
    return val;
  });
}

/**
 * Shallow equivalence on the subset of fields that affect the gateway's
 * runtime behavior. Used to decide whether a config-changed event
 * requires a bounce. Uses stableStringify so YAML round-trip key-order
 * changes don't cause false-positive bounces.
 */
function configsEquivalent(a: GatewayConfig, b: GatewayConfig): boolean {
  if (a.listenPort !== b.listenPort) return false;
  if (a.requestTimeoutSec !== b.requestTimeoutSec) return false;
  if (a.upstreamTimeoutSec !== b.upstreamTimeoutSec) return false;
  if (a.shutdownGracefulSec !== b.shutdownGracefulSec) return false;
  if (stableStringify(a.backends) !== stableStringify(b.backends)) return false;
  if (stableStringify(a.virtualKeys) !== stableStringify(b.virtualKeys)) return false;
  return true;
}

