import { describe, it, expect, vi, beforeEach } from 'vitest';
import { McpRouter } from '../src/router.js';
import type { UpstreamConnection, JsonRpcRequest, JsonRpcResponse, JsonRpcNotification } from '../src/types.js';
import type { McpdClient } from '../src/http/mcpd-client.js';
import { ProviderRegistry } from '../src/providers/registry.js';
import type { LlmProvider, CompletionResult } from '../src/providers/types.js';
import { createGatePlugin } from '../src/proxymodel/plugins/gate.js';
import { LLMProviderAdapter } from '../src/proxymodel/llm-adapter.js';
import { MemoryCache } from '../src/proxymodel/cache.js';

function mockUpstream(
  name: string,
  opts: { tools?: Array<{ name: string; description?: string }> } = {},
): UpstreamConnection {
  return {
    name,
    isAlive: vi.fn(() => true),
    close: vi.fn(async () => {}),
    onNotification: vi.fn(),
    send: vi.fn(async (req: JsonRpcRequest): Promise<JsonRpcResponse> => {
      if (req.method === 'tools/list') {
        return { jsonrpc: '2.0', id: req.id, result: { tools: opts.tools ?? [] } };
      }
      if (req.method === 'tools/call') {
        return {
          jsonrpc: '2.0',
          id: req.id,
          result: { content: [{ type: 'text', text: `Called ${(req.params as Record<string, unknown>)?.name}` }] },
        };
      }
      if (req.method === 'resources/list') {
        return { jsonrpc: '2.0', id: req.id, result: { resources: [] } };
      }
      if (req.method === 'prompts/list') {
        return { jsonrpc: '2.0', id: req.id, result: { prompts: [] } };
      }
      return { jsonrpc: '2.0', id: req.id, error: { code: -32601, message: 'Not found' } };
    }),
  } as UpstreamConnection;
}

function mockMcpdClient(prompts: Array<{ name: string; priority: number; summary: string | null; chapters: string[] | null; content: string; type?: string }> = []): McpdClient {
  return {
    get: vi.fn(async (path: string) => {
      if (path.includes('/prompts/visible')) {
        return prompts.map((p) => ({ ...p, type: p.type ?? 'prompt' }));
      }
      if (path.includes('/prompt-index')) {
        return prompts.map((p) => ({
          name: p.name,
          priority: p.priority,
          summary: p.summary,
          chapters: p.chapters,
        }));
      }
      return [];
    }),
    post: vi.fn(async () => ({})),
    put: vi.fn(async () => ({})),
    delete: vi.fn(async () => {}),
    forward: vi.fn(async () => ({ status: 200, body: {} })),
    withHeaders: vi.fn(function (this: McpdClient) { return this; }),
  } as unknown as McpdClient;
}

const samplePrompts = [
  { name: 'common-mistakes', priority: 10, summary: 'Critical safety rules everyone must follow', chapters: null, content: 'NEVER do X. ALWAYS do Y.' },
  { name: 'zigbee-pairing', priority: 7, summary: 'How to pair Zigbee devices with the hub', chapters: ['Setup', 'Troubleshooting'], content: 'Step 1: Put device in pairing mode...' },
  { name: 'mqtt-config', priority: 5, summary: 'MQTT broker configuration guide', chapters: ['Broker Setup', 'Authentication'], content: 'Configure the MQTT broker at...' },
  { name: 'security-policy', priority: 8, summary: 'Security policies for production deployments', chapters: ['Network', 'Auth'], content: 'All connections must use TLS...' },
];

function setupGatedRouter(
  opts: {
    gated?: boolean;
    prompts?: typeof samplePrompts;
    withLlm?: boolean;
    llmResponse?: string;
    byteBudget?: number;
  } = {},
): { router: McpRouter; mcpdClient: McpdClient } {
  const router = new McpRouter();
  const prompts = opts.prompts ?? samplePrompts;
  const mcpdClient = mockMcpdClient(prompts);
  router.setPromptConfig(mcpdClient, 'test-project');

  let providerRegistry: ProviderRegistry | null = null;
  if (opts.withLlm) {
    providerRegistry = new ProviderRegistry();
    const mockProvider: LlmProvider = {
      name: 'mock-heavy',
      complete: vi.fn().mockResolvedValue({
        content: opts.llmResponse ?? '{ "selectedNames": ["zigbee-pairing"], "reasoning": "User is working with zigbee" }',
        toolCalls: [],
        usage: { promptTokens: 100, completionTokens: 50, totalTokens: 150 },
        finishReason: 'stop',
      } satisfies CompletionResult),
      listModels: vi.fn().mockResolvedValue([]),
      isAvailable: vi.fn().mockResolvedValue(true),
    };
    providerRegistry.register(mockProvider);
    providerRegistry.assignTier(mockProvider.name, 'heavy');
  }

  // Wire gate plugin via setPlugin
  const gatePlugin = createGatePlugin({
    gated: opts.gated !== false,
    providerRegistry,
    byteBudget: opts.byteBudget,
  });
  router.setPlugin(gatePlugin);

  // Wire proxymodel services (needed for plugin context)
  const llmAdapter = providerRegistry ? new LLMProviderAdapter(providerRegistry) : {
    complete: async () => '',
    available: () => false,
  };
  router.setProxyModel('default', llmAdapter, new MemoryCache());

  return { router, mcpdClient };
}

describe('McpRouter gating', () => {
  describe('initialize with gating', () => {
    it('creates gated session on initialize', async () => {
      const { router } = setupGatedRouter();

      const res = await router.route(
        { jsonrpc: '2.0', id: 1, method: 'initialize' },
        { sessionId: 's1' },
      );

      expect(res.result).toBeDefined();
      // The session should be gated now
      const toolsRes = await router.route(
        { jsonrpc: '2.0', id: 2, method: 'tools/list' },
        { sessionId: 's1' },
      );
      const tools = (toolsRes.result as { tools: Array<{ name: string }> }).tools;
      expect(tools).toHaveLength(1);
      expect(tools[0]!.name).toBe('begin_session');
    });

    it('creates ungated session when project is not gated', async () => {
      const { router } = setupGatedRouter({ gated: false });
      router.addUpstream(mockUpstream('ha', { tools: [{ name: 'get_entities' }] }));

      await router.route(
        { jsonrpc: '2.0', id: 1, method: 'initialize' },
        { sessionId: 's1' },
      );

      const toolsRes = await router.route(
        { jsonrpc: '2.0', id: 2, method: 'tools/list' },
        { sessionId: 's1' },
      );
      const tools = (toolsRes.result as { tools: Array<{ name: string }> }).tools;
      const names = tools.map((t) => t.name);
      expect(names).toContain('ha/get_entities');
      expect(names).toContain('read_prompts');
      expect(names).toContain('propose_prompt');
      expect(names).not.toContain('begin_session');
    });
  });

  describe('tools/list gating', () => {
    it('shows only begin_session when session is gated', async () => {
      const { router } = setupGatedRouter();
      await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });

      const res = await router.route(
        { jsonrpc: '2.0', id: 2, method: 'tools/list' },
        { sessionId: 's1' },
      );

      const tools = (res.result as { tools: Array<{ name: string }> }).tools;
      expect(tools).toHaveLength(1);
      expect(tools[0]!.name).toBe('begin_session');
    });

    it('shows all tools plus read_prompts after ungating', async () => {
      const { router } = setupGatedRouter();
      router.addUpstream(mockUpstream('ha', { tools: [{ name: 'get_entities' }] }));
      await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });

      // Ungate via begin_session
      await router.route(
        { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'begin_session', arguments: { tags: ['zigbee'] } } },
        { sessionId: 's1' },
      );

      const toolsRes = await router.route(
        { jsonrpc: '2.0', id: 3, method: 'tools/list' },
        { sessionId: 's1' },
      );
      const tools = (toolsRes.result as { tools: Array<{ name: string }> }).tools;
      const names = tools.map((t) => t.name);
      expect(names).toContain('ha/get_entities');
      expect(names).toContain('propose_prompt');
      expect(names).toContain('read_prompts');
      expect(names).not.toContain('begin_session');
    });
  });

  describe('begin_session', () => {
    it('returns matched prompts with keyword matching', async () => {
      const { router } = setupGatedRouter();
      await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });

      const res = await router.route(
        { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'begin_session', arguments: { tags: ['zigbee', 'pairing'] } } },
        { sessionId: 's1' },
      );

      expect(res.error).toBeUndefined();
      const text = ((res.result as { content: Array<{ text: string }> }).content[0]!.text);
      // Should include priority 10 prompt
      expect(text).toContain('common-mistakes');
      expect(text).toContain('NEVER do X');
      // Should include zigbee-pairing (matches both tags)
      expect(text).toContain('zigbee-pairing');
      expect(text).toContain('pairing mode');
      // Should include encouragement
      expect(text).toContain('read_prompts');
    });

    it('includes priority 10 prompts even without matching tags', async () => {
      const { router } = setupGatedRouter();
      await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });

      const res = await router.route(
        { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'begin_session', arguments: { tags: ['unrelated-keyword'] } } },
        { sessionId: 's1' },
      );

      const text = ((res.result as { content: Array<{ text: string }> }).content[0]!.text);
      expect(text).toContain('common-mistakes');
      expect(text).toContain('NEVER do X');
    });

    it('uses LLM selection when provider is available', async () => {
      const { router } = setupGatedRouter({
        withLlm: true,
        llmResponse: '{ "selectedNames": ["zigbee-pairing", "security-policy"], "reasoning": "Zigbee pairing needs security awareness" }',
      });
      await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });

      const res = await router.route(
        { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'begin_session', arguments: { tags: ['zigbee'] } } },
        { sessionId: 's1' },
      );

      const text = ((res.result as { content: Array<{ text: string }> }).content[0]!.text);
      expect(text).toContain('Zigbee pairing needs security awareness');
      expect(text).toContain('zigbee-pairing');
      expect(text).toContain('security-policy');
      expect(text).toContain('common-mistakes'); // priority 10 always included
    });

    it('rejects empty tags', async () => {
      const { router } = setupGatedRouter();
      await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });

      const res = await router.route(
        { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'begin_session', arguments: { tags: [] } } },
        { sessionId: 's1' },
      );

      expect(res.error).toBeDefined();
      expect(res.error!.code).toBe(-32602);
    });

    it('returns message when session is already ungated', async () => {
      const { router } = setupGatedRouter();
      await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });

      // First call ungates
      await router.route(
        { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'begin_session', arguments: { tags: ['zigbee'] } } },
        { sessionId: 's1' },
      );

      // Second call tells user to use read_prompts
      const res = await router.route(
        { jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'begin_session', arguments: { tags: ['mqtt'] } } },
        { sessionId: 's1' },
      );

      const text = ((res.result as { content: Array<{ text: string }> }).content[0]!.text);
      expect(text).toContain('already started');
      expect(text).toContain('read_prompts');
    });

    it('lists remaining prompts for awareness', async () => {
      const { router } = setupGatedRouter();
      await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });

      const res = await router.route(
        { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'begin_session', arguments: { tags: ['zigbee'] } } },
        { sessionId: 's1' },
      );

      const text = ((res.result as { content: Array<{ text: string }> }).content[0]!.text);
      // Non-matching prompts should be listed as "other available prompts"
      // security-policy doesn't match 'zigbee' in keyword mode
      expect(text).toContain('security-policy');
    });
  });

  describe('read_prompts', () => {
    it('returns prompts matching keywords', async () => {
      const { router } = setupGatedRouter({ gated: false });
      await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });

      const res = await router.route(
        { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'read_prompts', arguments: { tags: ['mqtt', 'broker'] } } },
        { sessionId: 's1' },
      );

      expect(res.error).toBeUndefined();
      const text = ((res.result as { content: Array<{ text: string }> }).content[0]!.text);
      expect(text).toContain('mqtt-config');
      expect(text).toContain('Configure the MQTT broker');
    });

    it('filters out already-sent prompts', async () => {
      // Use a tight byte budget so begin_session only sends the top-scoring prompts
      const { router } = setupGatedRouter({ byteBudget: 80 });
      await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });

      // begin_session with ['zigbee'] sends common-mistakes (priority 10, Inf) and
      // zigbee-pairing (7+7=14) within 80 bytes. Lower-scored prompts overflow.
      await router.route(
        { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'begin_session', arguments: { tags: ['zigbee'] } } },
        { sessionId: 's1' },
      );

      // read_prompts for mqtt should find mqtt-config (wasn't fully sent), not re-send common-mistakes
      const res = await router.route(
        { jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'read_prompts', arguments: { tags: ['mqtt'] } } },
        { sessionId: 's1' },
      );

      const text = ((res.result as { content: Array<{ text: string }> }).content[0]!.text);
      expect(text).toContain('mqtt-config');
      // common-mistakes was already sent, should not appear again
      expect(text).not.toContain('NEVER do X');
    });

    it('returns message when no new prompts match', async () => {
      const { router } = setupGatedRouter({ prompts: [] });
      await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });

      const res = await router.route(
        { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'read_prompts', arguments: { tags: ['nonexistent'] } } },
        { sessionId: 's1' },
      );

      const text = ((res.result as { content: Array<{ text: string }> }).content[0]!.text);
      expect(text).toContain('No new matching prompts');
    });

    it('rejects empty tags', async () => {
      const { router } = setupGatedRouter({ gated: false });
      await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });

      const res = await router.route(
        { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'read_prompts', arguments: { tags: [] } } },
        { sessionId: 's1' },
      );

      expect(res.error).toBeDefined();
      expect(res.error!.code).toBe(-32602);
    });
  });

  describe('gated intercept', () => {
    it('auto-ungates when gated session calls a real tool', async () => {
      const { router } = setupGatedRouter();
      const ha = mockUpstream('ha', { tools: [{ name: 'get_entities' }] });
      router.addUpstream(ha);
      await router.discoverTools();
      await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });

      // Call a real tool while gated — should intercept, extract keywords, and route
      const res = await router.route(
        { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'ha/get_entities', arguments: { domain: 'light' } } },
        { sessionId: 's1' },
      );

      // Response should include the tool result
      expect(res.error).toBeUndefined();
      const result = res.result as { content: Array<{ type: string; text: string }> };
      // Should have briefing prepended
      expect(result.content.length).toBeGreaterThanOrEqual(1);

      // Session should now be ungated
      const toolsRes = await router.route(
        { jsonrpc: '2.0', id: 3, method: 'tools/list' },
        { sessionId: 's1' },
      );
      const tools = (toolsRes.result as { tools: Array<{ name: string }> }).tools;
      expect(tools.map((t) => t.name)).toContain('ha/get_entities');
    });

    it('includes project context in intercepted response', async () => {
      const { router } = setupGatedRouter();
      const ha = mockUpstream('ha', { tools: [{ name: 'get_entities' }] });
      router.addUpstream(ha);
      await router.discoverTools();
      await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });

      const res = await router.route(
        { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'ha/get_entities', arguments: { domain: 'light' } } },
        { sessionId: 's1' },
      );

      const result = res.result as { content: Array<{ type: string; text: string }> };
      // First content block should be the briefing (priority 10 at minimum)
      const briefing = result.content[0]!.text;
      expect(briefing).toContain('common-mistakes');
      expect(briefing).toContain('NEVER do X');
    });
  });

  describe('initialize instructions for gated projects', () => {
    it('includes gate message and prompt index in instructions', async () => {
      const { router } = setupGatedRouter();

      const res = await router.route(
        { jsonrpc: '2.0', id: 1, method: 'initialize' },
        { sessionId: 's1' },
      );

      const result = res.result as { instructions?: string };
      expect(result.instructions).toBeDefined();
      expect(result.instructions).toContain('begin_session');
      expect(result.instructions).toContain('gated session');
      // Should list available prompts
      expect(result.instructions).toContain('common-mistakes');
      expect(result.instructions).toContain('zigbee-pairing');
    });

    it('does not include gate message for non-gated projects', async () => {
      const { router } = setupGatedRouter({ gated: false });
      router.setInstructions('Base project instructions');

      const res = await router.route(
        { jsonrpc: '2.0', id: 1, method: 'initialize' },
        { sessionId: 's1' },
      );

      const result = res.result as { instructions?: string };
      expect(result.instructions).toBe('Base project instructions');
      expect(result.instructions).not.toContain('gated session');
    });

    it('preserves base instructions and appends gate message', async () => {
      const { router } = setupGatedRouter();
      router.setInstructions('You are a helpful assistant.');

      const res = await router.route(
        { jsonrpc: '2.0', id: 1, method: 'initialize' },
        { sessionId: 's1' },
      );

      const result = res.result as { instructions?: string };
      expect(result.instructions).toContain('You are a helpful assistant.');
      expect(result.instructions).toContain('begin_session');
    });

    it('sorts prompt index by priority descending', async () => {
      const { router } = setupGatedRouter();

      const res = await router.route(
        { jsonrpc: '2.0', id: 1, method: 'initialize' },
        { sessionId: 's1' },
      );

      const result = res.result as { instructions: string };
      const lines = result.instructions.split('\n');
      // Find the prompt index lines
      const promptLines = lines.filter((l) => l.startsWith('- ') && l.includes('priority'));
      // priority 10 should come first
      expect(promptLines[0]).toContain('common-mistakes');
      expect(promptLines[0]).toContain('priority 10');
    });
  });

  describe('session cleanup', () => {
    it('cleanupSession removes gate state, re-creates on next access', async () => {
      const { router } = setupGatedRouter();
      await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });

      // Session is gated
      let toolsRes = await router.route(
        { jsonrpc: '2.0', id: 2, method: 'tools/list' },
        { sessionId: 's1' },
      );
      expect((toolsRes.result as { tools: Array<{ name: string }> }).tools[0]!.name).toBe('begin_session');

      // Cleanup removes the context
      router.cleanupSession('s1');

      // After cleanup, getOrCreatePluginContext creates a fresh context and
      // calls onSessionCreate again → session is re-gated (gated=true config).
      toolsRes = await router.route(
        { jsonrpc: '2.0', id: 3, method: 'tools/list' },
        { sessionId: 's1' },
      );
      const tools = (toolsRes.result as { tools: Array<{ name: string }> }).tools;
      expect(tools[0]!.name).toBe('begin_session');
    });
  });

  describe('tool inventory', () => {
    it('includes tool names but NOT descriptions in gated initialize instructions', async () => {
      const { router } = setupGatedRouter();
      router.addUpstream(mockUpstream('ha', { tools: [{ name: 'get_entities', description: 'Get all entities' }] }));
      router.addUpstream(mockUpstream('node-red', { tools: [{ name: 'get_flows', description: 'Get all flows' }] }));

      const res = await router.route(
        { jsonrpc: '2.0', id: 1, method: 'initialize' },
        { sessionId: 's1' },
      );

      const result = res.result as { instructions: string };
      expect(result.instructions).toContain('ha/get_entities');
      expect(result.instructions).toContain('node-red/get_flows');
      expect(result.instructions).toContain('after begin_session');
      // Descriptions should NOT be in init instructions (names only)
      expect(result.instructions).not.toContain('Get all entities');
      expect(result.instructions).not.toContain('Get all flows');
    });

    it('includes tool names but NOT descriptions in begin_session response', async () => {
      const { router } = setupGatedRouter();
      router.addUpstream(mockUpstream('ha', { tools: [{ name: 'get_entities', description: 'Get all entities' }] }));
      await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });

      const res = await router.route(
        { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'begin_session', arguments: { tags: ['zigbee'] } } },
        { sessionId: 's1' },
      );

      const text = (res.result as { content: Array<{ text: string }> }).content[0]!.text;
      expect(text).toContain('ha/get_entities');
      expect(text).not.toContain('Get all entities');
    });

    it('includes retry instruction in begin_session response', async () => {
      const { router } = setupGatedRouter();
      await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });

      const res = await router.route(
        { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'begin_session', arguments: { tags: ['zigbee'] } } },
        { sessionId: 's1' },
      );

      const text = (res.result as { content: Array<{ text: string }> }).content[0]!.text;
      expect(text).toContain('Proceed with');
    });

    it('includes tool names but NOT descriptions in gated intercept briefing', async () => {
      const { router } = setupGatedRouter();
      const ha = mockUpstream('ha', { tools: [{ name: 'get_entities', description: 'Get all entities' }] });
      router.addUpstream(ha);
      await router.discoverTools();
      await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });

      const res = await router.route(
        { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'ha/get_entities', arguments: {} } },
        { sessionId: 's1' },
      );

      const result = res.result as { content: Array<{ type: string; text: string }> };
      const briefing = result.content[0]!.text;
      expect(briefing).toContain('ha/get_entities');
      expect(briefing).not.toContain('Get all entities');
    });
  });

  describe('notifications after ungating', () => {
    it('queues tools/list_changed after begin_session ungating', async () => {
      const { router } = setupGatedRouter();
      await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });

      await router.route(
        { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'begin_session', arguments: { tags: ['zigbee'] } } },
        { sessionId: 's1' },
      );

      const notifications = router.consumeNotifications('s1');
      expect(notifications).toHaveLength(1);
      expect(notifications[0]!.method).toBe('notifications/tools/list_changed');
    });

    it('queues tools/list_changed after gated intercept', async () => {
      const { router } = setupGatedRouter();
      const ha = mockUpstream('ha', { tools: [{ name: 'get_entities' }] });
      router.addUpstream(ha);
      await router.discoverTools();
      await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });

      await router.route(
        { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'ha/get_entities', arguments: {} } },
        { sessionId: 's1' },
      );

      const notifications = router.consumeNotifications('s1');
      expect(notifications).toHaveLength(1);
      expect(notifications[0]!.method).toBe('notifications/tools/list_changed');
    });

    it('consumeNotifications clears the queue', async () => {
      const { router } = setupGatedRouter();
      await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });

      await router.route(
        { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'begin_session', arguments: { tags: ['zigbee'] } } },
        { sessionId: 's1' },
      );

      // First consume returns the notification
      expect(router.consumeNotifications('s1')).toHaveLength(1);
      // Second consume returns empty
      expect(router.consumeNotifications('s1')).toHaveLength(0);
    });
  });

  describe('prompt index caching', () => {
    it('caches prompt index for 60 seconds', async () => {
      const { router, mcpdClient } = setupGatedRouter({ gated: false });
      await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });

      // First read_prompts call fetches from mcpd
      await router.route(
        { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'read_prompts', arguments: { tags: ['mqtt'] } } },
        { sessionId: 's1' },
      );

      // Second call should use cache
      await router.route(
        { jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'read_prompts', arguments: { tags: ['zigbee'] } } },
        { sessionId: 's1' },
      );

      // mcpdClient.get should have been called only once for prompts/visible
      const getCalls = vi.mocked(mcpdClient.get).mock.calls.filter((c) => (c[0] as string).includes('/prompts/visible'));
      expect(getCalls).toHaveLength(1);
    });
  });

  describe('begin_session description field', () => {
    it('accepts description and tokenizes to keywords', async () => {
      const { router } = setupGatedRouter();
      await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });

      const res = await router.route(
        { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'begin_session', arguments: { description: 'I want to pair a zigbee device with mqtt' } } },
        { sessionId: 's1' },
      );

      expect(res.error).toBeUndefined();
      const text = (res.result as { content: Array<{ text: string }> }).content[0]!.text;
      // Should match zigbee-pairing and mqtt-config via tokenized keywords
      expect(text).toContain('zigbee-pairing');
      expect(text).toContain('mqtt-config');
    });

    it('prefers tags over description when both provided', async () => {
      const { router } = setupGatedRouter();
      await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });

      const res = await router.route(
        { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'begin_session', arguments: { tags: ['mqtt'], description: 'zigbee pairing' } } },
        { sessionId: 's1' },
      );

      expect(res.error).toBeUndefined();
      const text = (res.result as { content: Array<{ text: string }> }).content[0]!.text;
      // Tags take priority — mqtt-config should match, zigbee-pairing should not
      expect(text).toContain('mqtt-config');
    });

    it('rejects calls with neither tags nor description', async () => {
      const { router } = setupGatedRouter();
      await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });

      const res = await router.route(
        { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'begin_session', arguments: {} } },
        { sessionId: 's1' },
      );

      expect(res.error).toBeDefined();
      expect(res.error!.code).toBe(-32602);
      expect(res.error!.message).toContain('tags or description');
    });

    it('rejects empty description with no tags', async () => {
      const { router } = setupGatedRouter();
      await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });

      const res = await router.route(
        { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'begin_session', arguments: { description: '   ' } } },
        { sessionId: 's1' },
      );

      expect(res.error).toBeDefined();
      expect(res.error!.code).toBe(-32602);
    });
  });

  describe('gate config refresh', () => {
    it('new sessions pick up gate config change (gated → ungated)', async () => {
      const { router } = setupGatedRouter({ gated: true });
      router.addUpstream(mockUpstream('ha', { tools: [{ name: 'get_entities' }] }));

      // First session is gated
      await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });
      let toolsRes = await router.route(
        { jsonrpc: '2.0', id: 2, method: 'tools/list' },
        { sessionId: 's1' },
      );
      expect((toolsRes.result as { tools: Array<{ name: string }> }).tools[0]!.name).toBe('begin_session');

      // Project config changes: gated → ungated (new plugin replaces old)
      router.setPlugin(createGatePlugin({ gated: false }));

      // New session should be ungated
      await router.route({ jsonrpc: '2.0', id: 3, method: 'initialize' }, { sessionId: 's2' });
      toolsRes = await router.route(
        { jsonrpc: '2.0', id: 4, method: 'tools/list' },
        { sessionId: 's2' },
      );
      const names = (toolsRes.result as { tools: Array<{ name: string }> }).tools.map((t) => t.name);
      expect(names).toContain('ha/get_entities');
      expect(names).not.toContain('begin_session');
    });

    it('new sessions pick up gate config change (ungated → gated)', async () => {
      const { router } = setupGatedRouter({ gated: false });
      router.addUpstream(mockUpstream('ha', { tools: [{ name: 'get_entities' }] }));

      // First session is ungated
      await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });
      let toolsRes = await router.route(
        { jsonrpc: '2.0', id: 2, method: 'tools/list' },
        { sessionId: 's1' },
      );
      let names = (toolsRes.result as { tools: Array<{ name: string }> }).tools.map((t) => t.name);
      expect(names).toContain('ha/get_entities');

      // Project config changes: ungated → gated
      router.setPlugin(createGatePlugin({ gated: true }));

      // New session should be gated
      await router.route({ jsonrpc: '2.0', id: 3, method: 'initialize' }, { sessionId: 's2' });
      toolsRes = await router.route(
        { jsonrpc: '2.0', id: 4, method: 'tools/list' },
        { sessionId: 's2' },
      );
      names = (toolsRes.result as { tools: Array<{ name: string }> }).tools.map((t) => t.name);
      expect(names).toHaveLength(1);
      expect(names[0]).toBe('begin_session');
    });

    it('existing gated sessions become ungated when plugin changes to ungated', async () => {
      const { router } = setupGatedRouter({ gated: true });
      router.addUpstream(mockUpstream('ha', { tools: [{ name: 'get_entities' }] }));

      // Session created while gated
      await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });

      // Config changes to ungated — new plugin replaces the old one
      router.setPlugin(createGatePlugin({ gated: false }));

      // With plugin architecture, the new plugin's gate doesn't know about s1,
      // so it treats it as ungated. This is correct behavior: when admin changes
      // a project from gated to ungated, existing sessions should also become ungated.
      const toolsRes = await router.route(
        { jsonrpc: '2.0', id: 2, method: 'tools/list' },
        { sessionId: 's1' },
      );
      const names = (toolsRes.result as { tools: Array<{ name: string }> }).tools.map((t) => t.name);
      expect(names).toContain('ha/get_entities');
      expect(names).not.toContain('begin_session');
    });

    it('already-ungated sessions remain ungated after config changes to gated', async () => {
      const { router } = setupGatedRouter({ gated: false });
      router.addUpstream(mockUpstream('ha', { tools: [{ name: 'get_entities' }] }));

      // Session created while ungated
      await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });

      // Config changes to gated
      router.setPlugin(createGatePlugin({ gated: true }));

      // Existing session s1 should remain ungated
      const toolsRes = await router.route(
        { jsonrpc: '2.0', id: 2, method: 'tools/list' },
        { sessionId: 's1' },
      );
      const names = (toolsRes.result as { tools: Array<{ name: string }> }).tools.map((t) => t.name);
      expect(names).toContain('ha/get_entities');
      expect(names).not.toContain('begin_session');
    });

    it('config refresh does not reset sessions that ungated via begin_session', async () => {
      const { router } = setupGatedRouter({ gated: true });
      router.addUpstream(mockUpstream('ha', { tools: [{ name: 'get_entities' }] }));

      // Session starts gated and ungates
      await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });
      await router.route(
        { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'begin_session', arguments: { tags: ['zigbee'] } } },
        { sessionId: 's1' },
      );

      // Config refreshes (still gated)
      router.setPlugin(createGatePlugin({ gated: true }));

      // Session should remain ungated — begin_session already completed
      const toolsRes = await router.route(
        { jsonrpc: '2.0', id: 3, method: 'tools/list' },
        { sessionId: 's1' },
      );
      const names = (toolsRes.result as { tools: Array<{ name: string }> }).tools.map((t) => t.name);
      expect(names).toContain('ha/get_entities');
      expect(names).not.toContain('begin_session');
    });
  });

  describe('response size cap', () => {
    it('truncates begin_session response over 24K chars', async () => {
      // Create prompts with very large content to exceed 24K
      // Use byteBudget large enough so content is included in fullContent
      const largePrompts = [
        { name: 'huge-prompt', priority: 10, summary: 'A very large prompt', chapters: null, content: 'x'.repeat(30_000) },
      ];
      const { router } = setupGatedRouter({ prompts: largePrompts, byteBudget: 50_000 });
      await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });

      const res = await router.route(
        { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'begin_session', arguments: { tags: ['huge'] } } },
        { sessionId: 's1' },
      );

      expect(res.error).toBeUndefined();
      const text = (res.result as { content: Array<{ text: string }> }).content[0]!.text;
      expect(text.length).toBeLessThanOrEqual(24_000 + 100); // allow for truncation message
      expect(text).toContain('[Response truncated');
    });

    it('does not truncate responses under 24K chars', async () => {
      const { router } = setupGatedRouter();
      await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });

      const res = await router.route(
        { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'begin_session', arguments: { tags: ['zigbee'] } } },
        { sessionId: 's1' },
      );

      const text = (res.result as { content: Array<{ text: string }> }).content[0]!.text;
      expect(text).not.toContain('[Response truncated');
    });
  });
});
