/**
 * Phase 3c — same-process gateway mount unit tests.
 *
 * Drives the mount handle directly with a fake ConfigManager + fake
 * BackendStatusRegistry so we don't need a Repository or live HTTP
 * upstream. Verifies:
 *
 *  - Gate returns 404 for /v1/* while disabled (and the worker bridge's
 *    own routes still work in the same Express app)
 *  - Flipping enabled: true brings the gateway up; /v1/models authed
 *    against the boot virtual_keys returns a backend list
 *  - Flipping back to false drains and re-404s
 *  - A backend list change while running triggers a bounce (stop + start)
 *  - misconfigured config (no backends) parks in `misconfigured` state
 *    with errors exposed
 */
import { describe, it, expect, beforeEach, vi } from 'vitest';
import express, { type Express } from 'express';
import request from 'supertest';
import { EventEmitter } from 'events';
import {
  isGatewayPath,
  classifyGatewayPath,
  mountGateway,
  type GatewayMountHandle,
} from './gateway-mount.js';
import type { ConfigManager } from '../config-manager.js';
import type { AppConfig } from '../config.js';
import type {
  BackendStatusRegistry,
  NodeStatus,
  NodeStatusListener,
  Unsubscribe,
} from '../engine/backend-status-registry.js';

function fakeRegistry(): BackendStatusRegistry & { emit(s: NodeStatus[]): void } {
  const listeners = new Set<NodeStatusListener>();
  const reg: BackendStatusRegistry = {
    start: () => {},
    stop: async () => {},
    getAll: () => [],
    subscribe: (l: NodeStatusListener): Unsubscribe => {
      listeners.add(l);
      return () => listeners.delete(l);
    },
    noteSubscriberActivity: () => {},
  };
  return Object.assign(reg, {
    emit: (snapshot: NodeStatus[]) => {
      for (const l of listeners) l(snapshot);
    },
  });
}

function fakeConfigManager(initial: Partial<AppConfig>): ConfigManager & {
  setConfig(next: Partial<AppConfig>): void;
} {
  let cfg = initial as AppConfig;
  const emitter = new EventEmitter();
  const cm = {
    getConfig: () => cfg,
    onConfigChanged: (cb: (c: AppConfig) => void) => {
      emitter.on('config-changed', cb);
    },
    setConfig: (next: Partial<AppConfig>) => {
      cfg = next as AppConfig;
      emitter.emit('config-changed', cfg);
    },
  } as unknown as ConfigManager & { setConfig(next: Partial<AppConfig>): void };
  return cm;
}

function gatewayConfigBlock(opts: {
  enabled: boolean;
  backends?: Array<{ id: string; endpoint: string; model: string; maxSlots: number }>;
  virtualKeys?: Array<{ key: string; team: string }>;
}): Partial<AppConfig> {
  // readGatewayConfig reads app['gateway'] directly and feeds it to
  // normalizeGatewayConfig, which expects camelCase (snake → camel
  // happens earlier inside loadConfig via transformKeys). Tests bypass
  // loadConfig so we pre-camelCase the block here.
  return {
    gateway: {
      enabled: opts.enabled,
      listenPort: 4000,
      backends: opts.backends ?? [
        { id: 'gpu-a', endpoint: 'http://localhost:9/v1', model: 'qwen3:8b', maxSlots: 4 },
      ],
      virtualKeys: opts.virtualKeys ?? [{ key: 'sk-test', team: 'alpha' }],
    },
  } as unknown as Partial<AppConfig>;
}

describe('isGatewayPath', () => {
  it('matches /v1/* paths', () => {
    expect(isGatewayPath('/v1/chat/completions')).toBe(true);
    expect(isGatewayPath('/v1/models')).toBe(true);
    expect(isGatewayPath('/v1/')).toBe(true);
  });
  it('A1: matches bare /v1 (no slash) as well as /v1/', () => {
    // Regression: previously isGatewayPath('/v1') was false because
    // the prefix match used '/v1/'. Bare /v1 silently bypassed the
    // gate. classifyGatewayPath now treats both consistently.
    expect(isGatewayPath('/v1')).toBe(true);
    expect(isGatewayPath('/v1/')).toBe(true);
  });
  it('matches /health/liveness but NOT bare /health (worker owns it)', () => {
    expect(isGatewayPath('/health/liveness')).toBe(true);
    // `isGatewayPath` keeps the back-compat semantics — only
    // gateway-only paths return true. Bare /health is
    // 'gateway-when-enabled' which is NOT gateway-only.
    expect(isGatewayPath('/health')).toBe(false);
  });
  it('does not match worker bridge routes', () => {
    expect(isGatewayPath('/api/local/tasks')).toBe(false);
    expect(isGatewayPath('/api/admin/gateway/keys')).toBe(false);
    expect(isGatewayPath('/auth/google')).toBe(false);
    expect(isGatewayPath('/ui/index.html')).toBe(false);
    expect(isGatewayPath('/metrics')).toBe(false);
  });
});

describe('classifyGatewayPath (CRITICAL-3 tri-state)', () => {
  it('classifies /v1/* as gateway-only', () => {
    expect(classifyGatewayPath('/v1/chat/completions')).toBe('gateway-only');
    expect(classifyGatewayPath('/v1/models')).toBe('gateway-only');
    expect(classifyGatewayPath('/v1')).toBe('gateway-only');
    expect(classifyGatewayPath('/v1/')).toBe('gateway-only');
  });
  it('classifies /health/liveness as gateway-only', () => {
    expect(classifyGatewayPath('/health/liveness')).toBe('gateway-only');
  });
  it('classifies /health as gateway-when-enabled', () => {
    expect(classifyGatewayPath('/health')).toBe('gateway-when-enabled');
  });
  it('returns false for everything else', () => {
    expect(classifyGatewayPath('/api/local/tasks')).toBe(false);
    expect(classifyGatewayPath('/healthz')).toBe(false);
    expect(classifyGatewayPath('/metrics')).toBe(false);
    expect(classifyGatewayPath('/')).toBe(false);
  });
});

describe('mountGateway lifecycle', () => {
  let app: Express;
  let configManager: ReturnType<typeof fakeConfigManager>;
  let registry: ReturnType<typeof fakeRegistry>;
  let mount: GatewayMountHandle;

  beforeEach(() => {
    app = express();
    configManager = fakeConfigManager(gatewayConfigBlock({ enabled: false }));
    registry = fakeRegistry();
    // A representative worker route that should NEVER be 404'd by the
    // gateway gate.
    app.get('/api/version', (_req, res) => res.json({ version: 'test' }));
    mount = mountGateway({
      app,
      configManager,
      repo: null,
      // CRITICAL-2: gateway owns its own registry built per config.
      // Tests inject via buildRegistry so the gateway sees the same
      // fake the test drives (snapshot emits / listener count).
      buildRegistry: () => registry,
      promRegistry: null,
    });
  });

  it('starts in disabled state', () => {
    expect(mount.getState()).toBe('disabled');
    expect(mount.getErrors()).toEqual([]);
  });

  it('returns 404 for /v1/* while disabled', async () => {
    await mount.applyConfig({
      enabled: false,
      listenPort: 4000,
      requestTimeoutSec: 600,
      upstreamTimeoutSec: 30,
      shutdownGracefulSec: 30,
      backends: [],
      virtualKeys: [],
    });
    const res = await request(app).get('/v1/models');
    expect(res.status).toBe(404);
  });

  it('preserves worker bridge routes regardless of gateway state', async () => {
    const r1 = await request(app).get('/api/version');
    expect(r1.status).toBe(200);
    expect(r1.body.version).toBe('test');

    // Enable gateway: worker route still works.
    configManager.setConfig(gatewayConfigBlock({ enabled: true }));
    // Wait for async applyConfig to drain.
    await new Promise(r => setImmediate(r));
    await new Promise(r => setImmediate(r));
    const r2 = await request(app).get('/api/version');
    expect(r2.status).toBe(200);
  });

  it('flipping enabled true brings the gateway up; /v1/models authed succeeds', async () => {
    configManager.setConfig(gatewayConfigBlock({ enabled: true }));
    // applyConfig is fire-and-forget from onConfigChanged; wait for the
    // mutex to drain so the running state stabilises before we hit it.
    for (let i = 0; i < 5; i++) await new Promise(r => setImmediate(r));
    expect(mount.getState()).toBe('running');

    const res = await request(app)
      .get('/v1/models')
      .set('Authorization', 'Bearer sk-test');
    expect(res.status).toBe(200);
    expect(res.body.data).toBeInstanceOf(Array);
    expect(res.body.data.length).toBe(1);
    expect(res.body.data[0].id).toBe('gpu-a');
  });

  it('flipping enabled false drains and re-404s', async () => {
    configManager.setConfig(gatewayConfigBlock({ enabled: true }));
    for (let i = 0; i < 5; i++) await new Promise(r => setImmediate(r));
    expect(mount.getState()).toBe('running');

    configManager.setConfig(gatewayConfigBlock({ enabled: false }));
    for (let i = 0; i < 5; i++) await new Promise(r => setImmediate(r));
    expect(mount.getState()).toBe('disabled');
    const res = await request(app)
      .get('/v1/models')
      .set('Authorization', 'Bearer sk-test');
    expect(res.status).toBe(404);
  });

  it('backend list change bounces (stop + start) without leaving state stale', async () => {
    configManager.setConfig(gatewayConfigBlock({ enabled: true }));
    for (let i = 0; i < 5; i++) await new Promise(r => setImmediate(r));
    expect(mount.getState()).toBe('running');

    configManager.setConfig(
      gatewayConfigBlock({
        enabled: true,
        backends: [
          { id: 'gpu-a', endpoint: 'http://localhost:9/v1', model: 'qwen3:8b', maxSlots: 4 },
          { id: 'gpu-b', endpoint: 'http://localhost:9/v1', model: 'qwen3:14b', maxSlots: 4 },
        ],
      }),
    );
    for (let i = 0; i < 5; i++) await new Promise(r => setImmediate(r));
    expect(mount.getState()).toBe('running');
    const res = await request(app)
      .get('/v1/models')
      .set('Authorization', 'Bearer sk-test');
    expect(res.body.data.map((m: { id: string }) => m.id).sort()).toEqual(['gpu-a', 'gpu-b']);
  });

  it('misconfigured config (no backends) parks in misconfigured state with errors', async () => {
    configManager.setConfig(
      gatewayConfigBlock({
        enabled: true,
        backends: [], // empty → validation fails
      }),
    );
    for (let i = 0; i < 5; i++) await new Promise(r => setImmediate(r));
    expect(mount.getState()).toBe('misconfigured');
    expect(mount.getErrors().length).toBeGreaterThan(0);
    // Gate stays closed.
    const res = await request(app).get('/v1/models');
    expect(res.status).toBe(404);
  });

  it('mount.stop() is idempotent and safe when never started', async () => {
    await expect(mount.stop()).resolves.toBeUndefined();
    await expect(mount.stop()).resolves.toBeUndefined();
    expect(mount.getState()).toBe('disabled');
  });

  it('CRITICAL-2: gateway owns its registry (built per config.backends, not worker list)', async () => {
    // Adversarial-review regression: previously the gateway router
    // borrowed the worker bridge's BackendStatusRegistry, which probes
    // provider.workers[].id (e.g. "w-a"). When gateway.backends[].id is
    // "gw-a" the router would get null status for every backend (id
    // mismatch), making /health empty + least-busy routing blind.
    //
    // The fix: gateway builds its own registry via buildRegistry(config)
    // on every start. This test sets up a fresh mount with a buildRegistry
    // spy and verifies (a) it gets called with the new config containing
    // gw-* ids, (b) the same fake registry is then used to serve
    // /v1/models (proving the router queries the gateway's registry).
    const localApp = express();
    const localCm = fakeConfigManager(gatewayConfigBlock({ enabled: false }));
    const gwRegistry = fakeRegistry();
    const buildSpy = vi.fn(() => gwRegistry);
    const localMount = mountGateway({
      app: localApp,
      configManager: localCm,
      repo: null,
      buildRegistry: buildSpy,
      promRegistry: null,
    });

    localCm.setConfig(
      gatewayConfigBlock({
        enabled: true,
        backends: [
          { id: 'gw-a', endpoint: 'http://localhost:9/v1', model: 'qwen3:8b', maxSlots: 4 },
        ],
      }),
    );
    for (let i = 0; i < 5; i++) await new Promise(r => setImmediate(r));
    expect(localMount.getState()).toBe('running');

    // buildRegistry was called once with the gateway config.
    expect(buildSpy).toHaveBeenCalledTimes(1);
    const cfg = buildSpy.mock.calls[0]![0];
    expect(cfg.backends[0]!.id).toBe('gw-a');

    // /v1/models comes from config (sanity check the mount wired the
    // gateway sub-app), and the gateway's router has the gw-a id.
    const res = await request(localApp)
      .get('/v1/models')
      .set('Authorization', 'Bearer sk-test');
    expect(res.status).toBe(200);
    expect(res.body.data.map((m: { id: string }) => m.id)).toContain('gw-a');

    await localMount.stop();
  });

  it('CRITICAL-3: /health LiteLLM-compat — gateway answers when running, bridge fallback when off', async () => {
    // Adversarial-review regression: server.ts used to register
    // app.get('/health', ...) BEFORE mountGateway, so the bridge
    // {status:'ok'} handler always won. Phase 1 promised LiteLLM-shape
    // `/health` JSON same-process — that was silently broken.
    //
    // The fix has two parts:
    //   1. classifyGatewayPath('/health') === 'gateway-when-enabled'
    //   2. server.ts registers the bridge `/health` handler AFTER
    //      mountGateway, so the gate's sub-app dispatch wins when the
    //      gateway is running.
    //
    // This test simulates that registration order locally.
    const localApp = express();
    const localCm = fakeConfigManager(gatewayConfigBlock({ enabled: false }));
    const localRegistry = fakeRegistry();
    const localMount = mountGateway({
      app: localApp,
      configManager: localCm,
      repo: null,
      buildRegistry: () => localRegistry,
      promRegistry: null,
    });
    // The bridge `/health` fallback (registered AFTER mountGateway).
    localApp.get('/health', (_req, res) => res.json({ status: 'ok' }));

    // Off → bridge fallback answers.
    let res = await request(localApp).get('/health');
    expect(res.status).toBe(200);
    expect(res.body).toEqual({ status: 'ok' });

    // On → gateway answers with LiteLLM shape.
    localCm.setConfig(gatewayConfigBlock({ enabled: true }));
    for (let i = 0; i < 5; i++) await new Promise(r => setImmediate(r));
    expect(localMount.getState()).toBe('running');

    res = await request(localApp).get('/health');
    expect(res.status).toBe(200);
    expect(res.body).toHaveProperty('healthy_endpoints');
    expect(res.body).toHaveProperty('unhealthy_endpoints');
    expect(res.body).toHaveProperty('healthy_count');
    expect(res.body).toHaveProperty('unhealthy_count');
    expect(res.body).not.toHaveProperty('status'); // not the bridge shape

    // /health/liveness is gateway-only — 200 from gateway, 404 when off.
    res = await request(localApp).get('/health/liveness');
    expect(res.status).toBe(200);

    // Flip off — bridge fallback again.
    localCm.setConfig(gatewayConfigBlock({ enabled: false }));
    for (let i = 0; i < 5; i++) await new Promise(r => setImmediate(r));
    res = await request(localApp).get('/health');
    expect(res.body).toEqual({ status: 'ok' });

    // /health/liveness now 404 (gateway-only + gateway off).
    res = await request(localApp).get('/health/liveness');
    expect(res.status).toBe(404);

    await localMount.stop();
  });

  it('CRITICAL-3: /v1/models stays 404 when gateway disabled (gateway-only path)', async () => {
    // Sanity check the gateway-only branch is unchanged: when off, /v1/*
    // returns 404 with the gateway-not-found shape — not bridge fall-through.
    const res = await request(app).get('/v1/models');
    expect(res.status).toBe(404);
    expect(res.body.error).toMatch(/not found:/);
  });

  it('configsEquivalent is key-order insensitive (no spurious bounce on YAML round-trip)', async () => {
    // Bring the gateway up with a baseline config.
    configManager.setConfig(
      gatewayConfigBlock({
        enabled: true,
        backends: [
          { id: 'gpu-a', endpoint: 'http://localhost:9/v1', model: 'qwen3:8b', maxSlots: 4 },
        ],
      }),
    );
    for (let i = 0; i < 5; i++) await new Promise(r => setImmediate(r));
    expect(mount.getState()).toBe('running');

    // Spy on registry.subscribe — a bounce re-subscribes, so the
    // subscribe-count growing tells us a bounce happened. We use the
    // fake-registry's listener tracking indirectly via the running
    // log line; instead, observe by checking that /v1/models stays
    // available across "the same config in a different key order".
    //
    // Build a config with the SAME semantic shape but each backend
    // object's keys reordered.
    const reorderedBackend = {
      maxSlots: 4,
      model: 'qwen3:8b',
      endpoint: 'http://localhost:9/v1',
      id: 'gpu-a',
    };
    configManager.setConfig({
      gateway: {
        enabled: true,
        listenPort: 4000,
        backends: [reorderedBackend],
        virtualKeys: [{ team: 'alpha', key: 'sk-test' }], // also reordered
      },
    } as unknown as Parameters<typeof configManager.setConfig>[0]);

    for (let i = 0; i < 5; i++) await new Promise(r => setImmediate(r));
    // Should still be running (no false bounce). Hit /v1/models to
    // confirm the gateway sub-app is still up.
    expect(mount.getState()).toBe('running');
    const res = await request(app)
      .get('/v1/models')
      .set('Authorization', 'Bearer sk-test');
    expect(res.status).toBe(200);
  });

  it('F1: rapid double-toggle does NOT drop the 2nd config (pending replay)', async () => {
    // Fire 4 transitions back-to-back. The mutex serialises them, but
    // F1 says we must not drop intent that arrived during a
    // starting/stopping window.
    const cfgEnabled = gatewayConfigBlock({ enabled: true });
    const cfgDisabled = gatewayConfigBlock({ enabled: false });
    configManager.setConfig(cfgEnabled);
    configManager.setConfig(cfgDisabled);
    configManager.setConfig(cfgEnabled);
    configManager.setConfig(cfgDisabled);
    // Drain the mutex chain.
    for (let i = 0; i < 20; i++) await new Promise(r => setImmediate(r));
    // The final intent was 'disabled' — the handle must converge there
    // even though multiple transitions arrived during in-flight ones.
    expect(mount.getState()).toBe('disabled');
  });

  it('F2: stop() on a misconfigured handle clears the state to disabled', async () => {
    // Park the mount in 'misconfigured' (enabled:true + empty backends).
    configManager.setConfig(
      gatewayConfigBlock({ enabled: true, backends: [] }),
    );
    for (let i = 0; i < 5; i++) await new Promise(r => setImmediate(r));
    expect(mount.getState()).toBe('misconfigured');
    expect(mount.getErrors().length).toBeGreaterThan(0);

    // Call stop(). Without the F2 fix, state stays 'misconfigured'
    // because stopGateway() returns early when shared is null.
    await mount.stop();
    expect(mount.getState()).toBe('disabled');
    expect(mount.getErrors()).toEqual([]);
  });

  it('F2: applyConfig({enabled:false}) on misconfigured clears the state', async () => {
    // Same scenario via the config-change path (the more common one in
    // production — admin opens the form, sees the misconfigured badge,
    // unchecks Enable, hits Save).
    configManager.setConfig(
      gatewayConfigBlock({ enabled: true, backends: [] }),
    );
    for (let i = 0; i < 5; i++) await new Promise(r => setImmediate(r));
    expect(mount.getState()).toBe('misconfigured');

    configManager.setConfig(gatewayConfigBlock({ enabled: false }));
    for (let i = 0; i < 5; i++) await new Promise(r => setImmediate(r));
    expect(mount.getState()).toBe('disabled');
    expect(mount.getErrors()).toEqual([]);
  });

  it('rapid enable -> disable transitions serialize via mutex (no interleaving)', async () => {
    // Fire enable + disable back-to-back without waiting.
    const p1 = mount.applyConfig({
      enabled: true,
      listenPort: 4000,
      requestTimeoutSec: 600,
      upstreamTimeoutSec: 30,
      shutdownGracefulSec: 30,
      backends: [
        { id: 'gpu-a', endpoint: 'http://localhost:9/v1', model: 'qwen3:8b', maxSlots: 4 },
      ],
      virtualKeys: [{ key: 'sk-test', team: 'alpha' }],
    });
    const p2 = mount.applyConfig({
      enabled: false,
      listenPort: 4000,
      requestTimeoutSec: 600,
      upstreamTimeoutSec: 30,
      shutdownGracefulSec: 30,
      backends: [],
      virtualKeys: [],
    });
    await Promise.all([p1, p2]);
    expect(mount.getState()).toBe('disabled');
  });
});
