import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createSystemHandlers } from './system.js';
import type { TokenUsageEntry } from './system.js';
import { createSessionHandlers } from './sessions.js';
import { createToolHandlers } from './tools.js';
import { createAgentHandlers } from './agent.js';
import { createIntentHandlers } from './intents.js';
import { createRoutingHandlers } from './routing.js';
import { createHistoryHandlers } from './history.js';
import { createCanvasHandlers } from './canvas.js';
import { createConfigHandlers, redactConfig } from './config.js';
import { createPairingHandlers } from './pairing.js';
import { PairingManager } from '../../channels/pairing.js';
import { LaneQueue } from '../lane-queue.js';
import { CanvasStore } from '../canvas-store.js';
import { ErrorCode } from '../protocol.js';
import type { GatewayRequest, GatewayResponse, GatewayError, GatewayEvent, OutboundMessage } from '../protocol.js';
import { ComponentRegistry } from '../../intents/index.js';
import { RoutingPolicy } from '../../routing/index.js';

function asSessionHandlerSessionManager(value: unknown): Parameters<typeof createSessionHandlers>[0]['sessionManager'] {
  return value as Parameters<typeof createSessionHandlers>[0]['sessionManager'];
}

function asToolRegistry(value: unknown): Parameters<typeof createToolHandlers>[0]['toolRegistry'] {
  return value as Parameters<typeof createToolHandlers>[0]['toolRegistry'];
}

function asToolExecutor(value: unknown): Parameters<typeof createToolHandlers>[0]['toolExecutor'] {
  return value as Parameters<typeof createToolHandlers>[0]['toolExecutor'];
}

function asSessionBridge(value: unknown): Parameters<typeof createAgentHandlers>[0]['sessionBridge'] {
  return value as Parameters<typeof createAgentHandlers>[0]['sessionBridge'];
}

function asHistorySessionManager(value: unknown): Parameters<typeof createHistoryHandlers>[0]['sessionManager'] {
  return value as Parameters<typeof createHistoryHandlers>[0]['sessionManager'];
}

function asConfigValue(value: unknown): Parameters<typeof createConfigHandlers>[0]['config'] {
  return value as Parameters<typeof createConfigHandlers>[0]['config'];
}

function asRedactInput(value: unknown): Parameters<typeof redactConfig>[0] {
  return value as Parameters<typeof redactConfig>[0];
}

function getPath(value: unknown, ...path: string[]): unknown {
  let current: unknown = value;
  for (const key of path) {
    if (!current || typeof current !== 'object') {
      return undefined;
    }
    current = (current as Record<string, unknown>)[key];
  }
  return current;
}

describe('system handlers', () => {
  const deps = {
    startTime: Date.now() - 60_000,
    version: '0.1.0',
    getSessionCount: () => 3,
    getToolCount: () => 6,
    getConnectionCount: () => 2,
  };
  const handlers = createSystemHandlers(deps);

  it('system.health returns status info', async () => {
    const req: GatewayRequest = { id: 1, method: 'system.health' };
    const result = await handlers['system.health'](req) as GatewayResponse;

    expect(result.id).toBe(1);
    const r = result.result as Record<string, unknown>;
    expect(r.status).toBe('ok');
    expect(r.version).toBe('0.1.0');
    expect(r.sessions).toBe(3);
    expect(r.tools).toBe(6);
    expect(r.connections).toBe(2);
    expect(typeof r.uptime).toBe('number');
    expect(r.uptime).toBeGreaterThanOrEqual(59);
  });

  it('system.services returns empty list when getServices is not provided', async () => {
    const req: GatewayRequest = { id: 2, method: 'system.services' };
    const result = await handlers['system.services'](req) as GatewayResponse;
    expect(result.id).toBe(2);
    expect(getPath(result.result, 'services')).toEqual([]);
  });

  it('system.services returns services from getServices callback', async () => {
    const handlers = createSystemHandlers({
      ...deps,
      getServices: () => [
        { name: 'telegram', type: 'channel', status: 'connected', description: 'Telegram bot' },
        { name: 'cron', type: 'automation', status: 'configured', description: 'Cron scheduler', itemCount: 2 },
      ],
    });

    const req: GatewayRequest = { id: 3, method: 'system.services' };
    const result = await handlers['system.services'](req) as GatewayResponse;
    expect(getPath(result.result, 'services')).toEqual([
      { name: 'telegram', type: 'channel', status: 'connected', description: 'Telegram bot' },
      { name: 'cron', type: 'automation', status: 'configured', description: 'Cron scheduler', itemCount: 2 },
    ]);
  });

  it('system.presence returns empty result when getPresence is not provided', async () => {
    const req: GatewayRequest = { id: 4, method: 'system.presence' };
    const result = await handlers['system.presence'](req) as GatewayResponse;
    expect(result.id).toBe(4);
    expect(getPath(result.result, 'presence')).toEqual([]);
    expect(getPath(result.result, 'summary')).toEqual({ total: 0, online: 0, offline: 0 });
  });

  it('system.presence returns filtered presence entries', async () => {
    const handlers = createSystemHandlers({
      ...deps,
      getPresence: ({ channel, status, limit } = {}) => {
        const all = [
          {
            channel: 'telegram',
            senderId: '1',
            senderName: 'alice',
            firstSeenAt: 1000,
            lastSeenAt: 2000,
            messageCount: 3,
            status: 'online' as const,
          },
          {
            channel: 'discord',
            senderId: '2',
            senderName: 'bob',
            firstSeenAt: 1000,
            lastSeenAt: 1500,
            messageCount: 1,
            status: 'offline' as const,
          },
        ];

        return all
          .filter((entry) => !channel || entry.channel === channel)
          .filter((entry) => !status || entry.status === status)
          .slice(0, limit ?? 100);
      },
    });

    const req: GatewayRequest = {
      id: 5,
      method: 'system.presence',
      params: { channel: 'telegram', status: 'online', limit: 10 },
    };
    const result = await handlers['system.presence'](req) as GatewayResponse;
    const presence = getPath(result.result, 'presence') as Array<{ channel: string }>;
    expect(presence).toHaveLength(1);
    expect(presence[0]?.channel).toBe('telegram');
    expect(getPath(result.result, 'summary')).toEqual({ total: 1, online: 1, offline: 0 });
  });

  it('system.location returns empty result when getNodeLocations is not provided', async () => {
    const req: GatewayRequest = { id: 6, method: 'system.location' };
    const result = await handlers['system.location'](req) as GatewayResponse;
    expect(result.id).toBe(6);
    expect(getPath(result.result, 'locations')).toEqual([]);
    expect(getPath(result.result, 'summary')).toEqual({ total: 0 });
  });

  it('system.location returns filtered node locations', async () => {
    const handlers = createSystemHandlers({
      ...deps,
      getNodeLocations: ({ role, nodeId, limit } = {}) => {
        const all = [
          {
            nodeId: 'node-1',
            role: 'companion',
            connectionId: 'c1',
            location: {
              latitude: 37.7,
              longitude: -122.4,
              source: 'gps' as const,
              capturedAt: 1000,
              receivedAt: 1005,
            },
          },
          {
            nodeId: 'node-2',
            role: 'observer',
            connectionId: 'c2',
            location: {
              latitude: 40.7,
              longitude: -74.0,
              source: 'network' as const,
              capturedAt: 900,
              receivedAt: 905,
            },
          },
        ];

        return all
          .filter((entry) => !role || entry.role === role)
          .filter((entry) => !nodeId || entry.nodeId === nodeId)
          .slice(0, limit ?? 100);
      },
    });

    const req: GatewayRequest = {
      id: 7,
      method: 'system.location',
      params: { role: 'companion', limit: 1 },
    };
    const result = await handlers['system.location'](req) as GatewayResponse;
    const locations = getPath(result.result, 'locations') as Array<{ nodeId: string }>;
    expect(locations).toHaveLength(1);
    expect(locations[0]?.nodeId).toBe('node-1');
    expect(getPath(result.result, 'summary')).toEqual({ total: 1 });
  });

  it('system.nodes returns empty result when getNodes is not provided', async () => {
    const req: GatewayRequest = { id: 8, method: 'system.nodes' };
    const result = await handlers['system.nodes'](req) as GatewayResponse;
    expect(result.id).toBe(8);
    expect(getPath(result.result, 'nodes')).toEqual([]);
    expect(getPath(result.result, 'summary')).toEqual({ total: 0 });
  });

  it('system.nodes returns filtered registered node snapshots', async () => {
    const handlers = createSystemHandlers({
      ...deps,
      getNodes: ({ role, platform, limit } = {}) => {
        const all = [
          {
            connectionId: 'c1',
            nodeId: 'companion-mac',
            role: 'companion',
            identity: 'will@example.com',
            protocolVersion: 1,
            capabilities: ['ui.canvas'],
            registeredAt: 100,
            status: { platform: 'macos' as const, appVersion: '0.3.0', powerSource: 'ac' as const, reportedAt: 120 },
          },
          {
            connectionId: 'c2',
            nodeId: 'observer-linux',
            role: 'observer',
            protocolVersion: 1,
            capabilities: [],
            registeredAt: 90,
            status: { platform: 'linux' as const, powerSource: 'unknown' as const, reportedAt: 95 },
          },
        ];
        return all
          .filter((entry) => !role || entry.role === role)
          .filter((entry) => !platform || entry.status?.platform === platform)
          .slice(0, limit ?? 100);
      },
    });

    const req: GatewayRequest = {
      id: 9,
      method: 'system.nodes',
      params: { role: 'companion', platform: 'macos', limit: 1 },
    };
    const result = await handlers['system.nodes'](req) as GatewayResponse;
    const nodes = getPath(result.result, 'nodes') as Array<{ nodeId: string }>;
    expect(nodes).toHaveLength(1);
    expect(nodes[0]?.nodeId).toBe('companion-mac');
    expect(getPath(result.result, 'summary')).toEqual({ total: 1 });
  });
});

describe('system.tokenUsage handler', () => {
  it('returns empty sessions when no getTokenUsage provided', async () => {
    const handlers = createSystemHandlers({
      startTime: Date.now(),
      version: '0.1.0',
      getSessionCount: () => 0,
      getToolCount: () => 0,
      getConnectionCount: () => 0,
    });

    const req: GatewayRequest = { id: 1, method: 'system.tokenUsage' };
    const result = await handlers['system.tokenUsage'](req) as GatewayResponse;

    expect(result.id).toBe(1);
    const r = result.result as { sessions: unknown[] };
    expect(r.sessions).toEqual([]);
  });

  it('returns session usage data from getTokenUsage callback', async () => {
    const mockUsage: TokenUsageEntry[] = [
      {
        sessionId: 'telegram:user1',
        primary: { inputTokens: 1000, outputTokens: 500, calls: 3 },
        delegation: { fast: { inputTokens: 200, outputTokens: 100, calls: 1 } },
        total: { inputTokens: 1200, outputTokens: 600, calls: 4, estimatedCost: 0.0234 },
      },
      {
        sessionId: 'ws:abc-123',
        primary: { inputTokens: 50, outputTokens: 25, calls: 1 },
        delegation: {},
        total: { inputTokens: 50, outputTokens: 25, calls: 1, estimatedCost: 0 },
      },
    ];

    const handlers = createSystemHandlers({
      startTime: Date.now(),
      version: '0.1.0',
      getSessionCount: () => 2,
      getToolCount: () => 0,
      getConnectionCount: () => 1,
      getTokenUsage: () => mockUsage,
    });

    const req: GatewayRequest = { id: 2, method: 'system.tokenUsage' };
    const result = await handlers['system.tokenUsage'](req) as GatewayResponse;

    expect(result.id).toBe(2);
    const r = result.result as { sessions: typeof mockUsage };
    expect(r.sessions).toHaveLength(2);
    expect(r.sessions[0].sessionId).toBe('telegram:user1');
    expect(r.sessions[0].total.inputTokens).toBe(1200);
    expect(r.sessions[0].total.estimatedCost).toBe(0.0234);
    expect(r.sessions[0].delegation.fast.inputTokens).toBe(200);
    expect(r.sessions[1].sessionId).toBe('ws:abc-123');
    expect(r.sessions[1].total.calls).toBe(1);
  });
});

describe('system.contextUsage handler', () => {
  it('returns empty sessions when no getContextUsage provided', async () => {
    const handlers = createSystemHandlers({
      startTime: Date.now(),
      version: '0.1.0',
      getSessionCount: () => 0,
      getToolCount: () => 0,
      getConnectionCount: () => 0,
    });

    const req: GatewayRequest = { id: 21, method: 'system.contextUsage' };
    const result = await handlers['system.contextUsage'](req) as GatewayResponse;

    expect(result.id).toBe(21);
    const r = result.result as { sessions: unknown[] };
    expect(r.sessions).toEqual([]);
  });

  it('returns session context budget data from getContextUsage callback', async () => {
    const mockUsage = [
      {
        sessionId: 'telegram:user1',
        budget: {
          estimatedTokens: 120000,
          contextWindow: 200000,
          remainingTokens: 80000,
          usagePct: 60,
          thresholdPct: 80,
          thresholdTokens: 160000,
          shouldCompact: false,
        },
      },
    ];

    const handlers = createSystemHandlers({
      startTime: Date.now(),
      version: '0.1.0',
      getSessionCount: () => 1,
      getToolCount: () => 0,
      getConnectionCount: () => 1,
      getContextUsage: () => mockUsage,
    });

    const req: GatewayRequest = { id: 22, method: 'system.contextUsage' };
    const result = await handlers['system.contextUsage'](req) as GatewayResponse;

    const r = result.result as { sessions: typeof mockUsage };
    expect(r.sessions).toHaveLength(1);
    expect(r.sessions[0].sessionId).toBe('telegram:user1');
    expect(r.sessions[0].budget.usagePct).toBe(60);
    expect(r.sessions[0].budget.shouldCompact).toBe(false);
  });
});

describe('system.sessionAnalytics handler', () => {
  it('returns empty analytics when callback is not provided', async () => {
    const handlers = createSystemHandlers({
      startTime: Date.now(),
      version: '0.1.0',
      getSessionCount: () => 0,
      getToolCount: () => 0,
      getConnectionCount: () => 0,
    });

    const req: GatewayRequest = { id: 3, method: 'system.sessionAnalytics' };
    const result = await handlers['system.sessionAnalytics'](req) as GatewayResponse;

    expect(result.id).toBe(3);
    const r = result.result as {
      daily: unknown[];
      topSessions: unknown[];
      topTools: unknown[];
      topTopics: unknown[];
      averageMessagesPerSession: number;
      totalSessions: number;
      totalMessages: number;
    };
    expect(r.daily).toEqual([]);
    expect(r.topSessions).toEqual([]);
    expect(r.topTools).toEqual([]);
    expect(r.topTopics).toEqual([]);
    expect(r.averageMessagesPerSession).toBe(0);
    expect(r.totalSessions).toBe(0);
    expect(r.totalMessages).toBe(0);
  });

  it('returns analytics from callback', async () => {
    const getSessionAnalytics = vi.fn(() => ({
      daily: [{ day: '2026-02-16', sessions: 2, messages: 8 }],
      topSessions: [{ sessionId: 'telegram:1', messages: 5, lastActivity: 1708080000 }],
      topTools: [{ toolName: 'web.search', executions: 4 }],
      topTopics: [{ topic: 'kubernetes', occurrences: 3 }],
      averageMessagesPerSession: 4,
      totalSessions: 2,
      totalMessages: 8,
    }));

    const handlers = createSystemHandlers({
      startTime: Date.now(),
      version: '0.1.0',
      getSessionCount: () => 2,
      getToolCount: () => 0,
      getConnectionCount: () => 1,
      getSessionAnalytics,
    });

    const req: GatewayRequest = {
      id: 4,
      method: 'system.sessionAnalytics',
      params: { days: 7, topLimit: 5 },
    };
    const result = await handlers['system.sessionAnalytics'](req) as GatewayResponse;

    expect(getSessionAnalytics).toHaveBeenCalledWith({ days: 7, topLimit: 5 });
    expect(getPath(result.result, 'totalSessions')).toBe(2);
    expect(getPath(result.result, 'daily')).toEqual([{ day: '2026-02-16', sessions: 2, messages: 8 }]);
    expect(getPath(result.result, 'topTools')).toEqual([{ toolName: 'web.search', executions: 4 }]);
    expect(getPath(result.result, 'topTopics')).toEqual([{ topic: 'kubernetes', occurrences: 3 }]);
  });
});

describe('session handlers', () => {
  const mockHistory = [
    { role: 'user' as const, content: 'hello' },
    { role: 'assistant' as const, content: 'hi' },
  ];

  const mockSession = {
    id: 'ws:test',
    addMessage: vi.fn(),
    getHistory: vi.fn(() => mockHistory),
    clear: vi.fn(),
    replaceHistory: vi.fn(),
  };

  const mockSessionManager = {
    listSessions: vi.fn(() => ['ws:test']),
    getSession: vi.fn(() => mockSession),
    transferSession: vi.fn(),
    closeSession: vi.fn(),
  };

  const handlers = createSessionHandlers({
    sessionManager: asSessionHandlerSessionManager(mockSessionManager),
  });

  beforeEach(() => {
    vi.clearAllMocks();
    mockSessionManager.listSessions.mockReturnValue(['ws:test']);
    mockSessionManager.getSession.mockReturnValue(mockSession);
    mockSession.getHistory.mockReturnValue(mockHistory);
  });

  it('sessions.list returns session list with message counts', async () => {
    const req: GatewayRequest = { id: 1, method: 'sessions.list' };
    const result = await handlers['sessions.list'](req) as GatewayResponse;

    expect(result.id).toBe(1);
    const r = result.result as { sessions: Array<{ id: string; messageCount: number }> };
    expect(r.sessions).toHaveLength(1);
    expect(r.sessions[0].id).toBe('ws:test');
    expect(r.sessions[0].messageCount).toBe(2);
  });

  it('sessions.history returns messages with pagination', async () => {
    const req: GatewayRequest = { id: 2, method: 'sessions.history', params: { sessionId: 'ws:test', limit: 1, offset: 0 } };
    const result = await handlers['sessions.history'](req) as GatewayResponse;

    const r = result.result as { messages: unknown[]; total: number };
    expect(r.messages).toHaveLength(1);
    expect(r.total).toBe(2);
  });

  it('sessions.history requires sessionId', async () => {
    const req: GatewayRequest = { id: 3, method: 'sessions.history', params: {} };
    const result = await handlers['sessions.history'](req) as GatewayError;

    expect(result.error.code).toBe(ErrorCode.InvalidRequest);
  });

  it('sessions.create creates a new session', async () => {
    const req: GatewayRequest = { id: 4, method: 'sessions.create', params: { sessionId: 'ws:new' } };
    const result = await handlers['sessions.create'](req) as GatewayResponse;

    const r = result.result as { sessionId: string };
    expect(r.sessionId).toBe('ws:new');
    expect(mockSessionManager.getSession).toHaveBeenCalledWith('ws', 'new');
  });

  it('sessions.create auto-generates session ID', async () => {
    const req: GatewayRequest = { id: 5, method: 'sessions.create' };
    const result = await handlers['sessions.create'](req) as GatewayResponse;

    const r = result.result as { sessionId: string };
    expect(r.sessionId).toMatch(/^ws:\d+$/);
  });
});

describe('canvas handlers', () => {
  it('supports put/get/list/delete/clear lifecycle', async () => {
    const handlers = createCanvasHandlers({ store: new CanvasStore() });

    const putReq: GatewayRequest = {
      id: 1,
      method: 'canvas.put',
      params: {
        sessionId: 'ws:abc',
        artifactId: 'card-1',
        type: 'note',
        title: 'Draft',
        content: { text: 'hello' },
      },
    };
    const putRes = await handlers['canvas.put'](putReq) as GatewayResponse;
    expect(getPath(putRes.result, 'artifact', 'id')).toBe('card-1');

    const getRes = await handlers['canvas.get']({
      id: 2,
      method: 'canvas.get',
      params: { sessionId: 'ws:abc', artifactId: 'card-1' },
    }) as GatewayResponse;
    expect(getPath(getRes.result, 'artifact', 'title')).toBe('Draft');

    const listRes = await handlers['canvas.list']({
      id: 3,
      method: 'canvas.list',
      params: { sessionId: 'ws:abc' },
    }) as GatewayResponse;
    expect((getPath(listRes.result, 'artifacts') as unknown[]).length).toBe(1);

    const delRes = await handlers['canvas.delete']({
      id: 4,
      method: 'canvas.delete',
      params: { sessionId: 'ws:abc', artifactId: 'card-1' },
    }) as GatewayResponse;
    expect(getPath(delRes.result, 'deleted')).toBe(true);

    await handlers['canvas.put']({
      id: 5,
      method: 'canvas.put',
      params: { sessionId: 'ws:abc', artifactId: 'card-2', type: 'note', content: 'a' },
    });
    await handlers['canvas.put']({
      id: 6,
      method: 'canvas.put',
      params: { sessionId: 'ws:abc', artifactId: 'card-3', type: 'note', content: 'b' },
    });
    const clearRes = await handlers['canvas.clear']({
      id: 7,
      method: 'canvas.clear',
      params: { sessionId: 'ws:abc' },
    }) as GatewayResponse;
    expect(getPath(clearRes.result, 'cleared')).toBe(2);
  });

  it('validates required params', async () => {
    const handlers = createCanvasHandlers({ store: new CanvasStore() });

    const missingSession = await handlers['canvas.list']({
      id: 8,
      method: 'canvas.list',
      params: {},
    }) as GatewayError;
    expect(missingSession.error.code).toBe(ErrorCode.InvalidRequest);

    const missingContent = await handlers['canvas.put']({
      id: 9,
      method: 'canvas.put',
      params: { sessionId: 'ws:abc', type: 'note' },
    }) as GatewayError;
    expect(missingContent.error.code).toBe(ErrorCode.InvalidRequest);
  });
});

describe('tool handlers', () => {
  const mockTool = {
    name: 'test.tool',
    description: 'A test tool',
    inputSchema: { type: 'object' as const, properties: {} },
    execute: vi.fn(),
  };

  const mockRegistry = {
    list: vi.fn(() => [mockTool]),
    filteredList: vi.fn(() => [mockTool]),
    get: vi.fn((name: string) => (name === 'test.tool' ? mockTool : undefined)),
    register: vi.fn(),
    toAnthropicFormat: vi.fn(),
    toOpenAIFormat: vi.fn(),
  };

  const mockExecutor = {
    execute: vi.fn(async () => ({ success: true, output: 'done' })),
  };

  const handlers = createToolHandlers({
    toolRegistry: asToolRegistry(mockRegistry),
    toolExecutor: asToolExecutor(mockExecutor),
  });

  beforeEach(() => {
    vi.clearAllMocks();
    mockRegistry.list.mockReturnValue([mockTool]);
    mockRegistry.filteredList.mockReturnValue([mockTool]);
    mockRegistry.get.mockImplementation((name: string) => (name === 'test.tool' ? mockTool : undefined));
    mockExecutor.execute.mockResolvedValue({ success: true, output: 'done' });
  });

  it('tools.list returns tool definitions', async () => {
    const req: GatewayRequest = { id: 1, method: 'tools.list' };
    const result = await handlers['tools.list'](req) as GatewayResponse;

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

  it('tools.invoke executes a tool', async () => {
    const req: GatewayRequest = { id: 2, method: 'tools.invoke', params: { tool: 'test.tool', args: {} } };
    const result = await handlers['tools.invoke'](req) as GatewayResponse;

    expect(result.result).toEqual({ success: true, output: 'done' });
    expect(mockExecutor.execute).toHaveBeenCalledWith('test.tool', {});
  });

  it('tools.invoke errors on missing tool name', async () => {
    const req: GatewayRequest = { id: 3, method: 'tools.invoke', params: {} };
    const result = await handlers['tools.invoke'](req) as GatewayError;
    expect(result.error.code).toBe(ErrorCode.InvalidRequest);
  });

  it('tools.invoke errors on unknown tool', async () => {
    const req: GatewayRequest = { id: 4, method: 'tools.invoke', params: { tool: 'unknown' } };
    const result = await handlers['tools.invoke'](req) as GatewayError;
    expect(result.error.code).toBe(ErrorCode.ToolNotFound);
  });
});

describe('agent handlers', () => {
  const mockAgent = {
    process: vi.fn(async () => 'response text'),
    consumeContextAlert: vi.fn(() => undefined),
    getContextBudget: vi.fn(() => ({
      estimatedTokens: 0,
      contextWindow: 128000,
      remainingTokens: 128000,
      usagePct: 0,
      thresholdPct: 80,
      thresholdTokens: 102400,
      shouldCompact: false,
    })),
    setOnToolUse: vi.fn(),
  };

  const mockBridge = {
    getAgent: vi.fn(() => mockAgent),
    getSessionId: vi.fn(() => 'ws:conn-1'),
    isBusy: vi.fn(() => false),
    cancel: vi.fn(() => false),
    setBusy: vi.fn(),
    setOnToolUse: vi.fn(),
  };

  const laneQueue = new LaneQueue();

  const handlers = createAgentHandlers({
    sessionBridge: asSessionBridge(mockBridge),
    laneQueue,
  });

  beforeEach(() => {
    vi.clearAllMocks();
    mockBridge.isBusy.mockReturnValue(false);
    mockBridge.cancel.mockReturnValue(false);
    mockBridge.getAgent.mockReturnValue(mockAgent);
    mockAgent.process.mockResolvedValue('response text');
  });

  it('agent.send processes message and sends done event', async () => {
    const req: GatewayRequest = { id: 1, method: 'agent.send', params: { message: 'hello', connectionId: 'conn-1' } };
    const sent: OutboundMessage[] = [];
    const send = vi.fn((msg: OutboundMessage) => sent.push(msg));

    await handlers['agent.send'](req, send);

    expect(mockAgent.process).toHaveBeenCalledWith('hello', undefined);
    expect(sent).toHaveLength(1);
    const doneEvent = sent[0] as GatewayEvent;
    expect(doneEvent.event).toBe('done');
    expect(getPath(doneEvent.data, 'content')).toBe('response text');
  });

  it('agent.send passes attachments to agent.process', async () => {
    const attachments = [
      { mimeType: 'image/png', data: 'iVBOR...', filename: 'screenshot.png' },
      { mimeType: 'application/pdf', url: 'https://example.com/doc.pdf' },
    ];
    const req: GatewayRequest = {
      id: 10,
      method: 'agent.send',
      params: { message: 'describe this', connectionId: 'conn-1', attachments },
    };
    const sent: OutboundMessage[] = [];
    const send = vi.fn((msg: OutboundMessage) => sent.push(msg));

    await handlers['agent.send'](req, send);

    expect(mockAgent.process).toHaveBeenCalledWith('describe this', [
      { mimeType: 'image/png', data: 'iVBOR...', url: undefined, filename: 'screenshot.png' },
      { mimeType: 'application/pdf', data: undefined, url: 'https://example.com/doc.pdf', filename: undefined },
    ]);
    const doneEvent = sent[0] as GatewayEvent;
    expect(doneEvent.event).toBe('done');
  });

  it('agent.send works with empty attachments array', async () => {
    const req: GatewayRequest = {
      id: 11,
      method: 'agent.send',
      params: { message: 'hi', connectionId: 'conn-1', attachments: [] },
    };
    const sent: OutboundMessage[] = [];
    const send = vi.fn((msg: OutboundMessage) => sent.push(msg));

    await handlers['agent.send'](req, send);

    expect(mockAgent.process).toHaveBeenCalledWith('hi', []);
    expect(sent).toHaveLength(1);
  });

  it('agent.send accepts attachment-only requests', async () => {
    const req: GatewayRequest = {
      id: 12,
      method: 'agent.send',
      params: {
        connectionId: 'conn-1',
        attachments: [{ mimeType: 'image/png', data: 'iVBOR...' }],
      },
    };
    const sent: OutboundMessage[] = [];
    const send = vi.fn((msg: OutboundMessage) => sent.push(msg));

    await handlers['agent.send'](req, send);

    expect(mockAgent.process).toHaveBeenCalledWith('', [
      { mimeType: 'image/png', data: 'iVBOR...', url: undefined, filename: undefined },
    ]);
    expect(sent).toHaveLength(1);
    expect((sent[0] as GatewayEvent).event).toBe('done');
  });

  it('agent.send requires message or attachments', async () => {
    const req: GatewayRequest = { id: 2, method: 'agent.send', params: { connectionId: 'conn-1' } };
    const send = vi.fn();
    const result = await handlers['agent.send'](req, send) as GatewayError;

    expect(result.error.code).toBe(ErrorCode.InvalidRequest);
    expect(result.error.message).toContain('message');
  });

  it('agent.send queues concurrent requests instead of rejecting', async () => {
    // Simulate the first request blocking
    let resolveFirst!: () => void;
    const firstBlocks = new Promise<void>((r) => { resolveFirst = r; });
    let callCount = 0;
    mockAgent.process.mockImplementation(async () => {
      callCount++;
      if (callCount === 1) {
        await firstBlocks;
        return 'first response';
      }
      return 'second response';
    });

    const req1: GatewayRequest = { id: 3, method: 'agent.send', params: { message: 'first', connectionId: 'conn-1' } };
    const req2: GatewayRequest = { id: 4, method: 'agent.send', params: { message: 'second', connectionId: 'conn-1' } };
    const sent1: OutboundMessage[] = [];
    const sent2: OutboundMessage[] = [];

    const p1 = handlers['agent.send'](req1, vi.fn((msg: OutboundMessage) => sent1.push(msg)));
    const p2 = handlers['agent.send'](req2, vi.fn((msg: OutboundMessage) => sent2.push(msg)));

    // Release the first request
    resolveFirst();
    await Promise.all([p1, p2]);

    // Both should have completed — no AgentBusy error
    expect(sent1).toHaveLength(1);
    expect((sent1[0] as GatewayEvent).event).toBe('done');
    expect(sent2).toHaveLength(1);
    expect((sent2[0] as GatewayEvent).event).toBe('done');
    expect(mockAgent.process).toHaveBeenCalledTimes(2);
  });

  it('agent.send handles errors gracefully', async () => {
    mockAgent.process.mockRejectedValue(new Error('model failed'));
    const req: GatewayRequest = { id: 4, method: 'agent.send', params: { message: 'hi', connectionId: 'conn-1' } };
    const sent: OutboundMessage[] = [];
    const send = vi.fn((msg: OutboundMessage) => sent.push(msg));

    await handlers['agent.send'](req, send);

    const errorEvent = sent[0] as GatewayEvent;
    expect(errorEvent.event).toBe('error');
    expect(getPath(errorEvent.data, 'message')).toBe('model failed');
  });

  it('agent.send sets and cleans up tool use callback', async () => {
    const req: GatewayRequest = { id: 5, method: 'agent.send', params: { message: 'hi', connectionId: 'conn-1' } };
    const send = vi.fn();

    await handlers['agent.send'](req, send);

    // setOnToolUse called twice: once to set callback, once to clear it
    expect(mockBridge.setOnToolUse).toHaveBeenCalledTimes(2);
    expect(mockBridge.setOnToolUse).toHaveBeenLastCalledWith('conn-1', undefined);
  });

  it('agent.send sets busy state correctly', async () => {
    const req: GatewayRequest = { id: 6, method: 'agent.send', params: { message: 'hi', connectionId: 'conn-1' } };
    const send = vi.fn();

    await handlers['agent.send'](req, send);

    expect(mockBridge.setBusy).toHaveBeenCalledWith('conn-1', true);
    expect(mockBridge.setBusy).toHaveBeenCalledWith('conn-1', false);
  });

  it('agent.cancel returns cancelled state', async () => {
    mockBridge.cancel.mockReturnValue(true);
    const req: GatewayRequest = { id: 7, method: 'agent.cancel', params: { connectionId: 'conn-1' } };
    const result = await handlers['agent.cancel'](req) as GatewayResponse;

    expect(getPath(result.result, 'cancelled')).toBe(true);
    expect(getPath(result.result, 'message')).toContain('Cancellation requested');
    expect(mockBridge.cancel).toHaveBeenCalledWith('conn-1');
  });

  it('agent.cancel returns not-cancelled when no active operation exists', async () => {
    mockBridge.cancel.mockReturnValue(false);
    const req: GatewayRequest = { id: 8, method: 'agent.cancel', params: { connectionId: 'conn-1' } };
    const result = await handlers['agent.cancel'](req) as GatewayResponse;

    expect(getPath(result.result, 'cancelled')).toBe(false);
    expect(getPath(result.result, 'message')).toContain('No active operation');
  });
});

describe('intent handlers', () => {
  it('intents.list returns configured rules', async () => {
    const registry = new ComponentRegistry({ matchThreshold: 0.6 });
    registry.register({
      name: 'deploy-route',
      patterns: ['deploy *'],
      target: { type: 'agent', name: 'coder' },
      priority: 5,
      enabled: true,
    });

    const handlers = createIntentHandlers({
      intentRegistry: registry,
      enabled: true,
    });

    const req: GatewayRequest = { id: 10, method: 'intents.list' };
    const result = await handlers['intents.list'](req) as GatewayResponse;
    const payload = result.result as { enabled: boolean; rules: Array<{ name: string }> };

    expect(payload.enabled).toBe(true);
    expect(payload.rules).toHaveLength(1);
    expect(payload.rules[0].name).toBe('deploy-route');
  });

  it('intents.match returns best rule match', async () => {
    const registry = new ComponentRegistry({ matchThreshold: 0.5 });
    registry.register({
      name: 'deploy-route',
      patterns: ['deploy *'],
      target: { type: 'agent', name: 'coder' },
      priority: 5,
      enabled: true,
    });

    const handlers = createIntentHandlers({
      intentRegistry: registry,
      enabled: true,
    });

    const req: GatewayRequest = {
      id: 11,
      method: 'intents.match',
      params: { input: 'deploy backend service' },
    };
    const result = await handlers['intents.match'](req) as GatewayResponse;
    const payload = result.result as { match: { rule: { name: string } } };

    expect(payload.match.rule.name).toBe('deploy-route');
  });
});

describe('routing handlers', () => {
  it('routing.decide returns match and policy decision', async () => {
    const registry = new ComponentRegistry({ matchThreshold: 0.5 });
    registry.register({
      name: 'deploy-route',
      patterns: ['deploy *'],
      target: { type: 'agent', name: 'coder' },
      priority: 5,
      enabled: true,
    });
    const policy = new RoutingPolicy({
      enabled: true,
      fastPathThreshold: 0.7,
      llmThreshold: 0.3,
      defaultPath: 'llm',
    });
    const handlers = createRoutingHandlers({
      intentRegistry: registry,
      routingPolicy: policy,
    });

    const req: GatewayRequest = {
      id: 12,
      method: 'routing.decide',
      params: { input: 'deploy service' },
    };
    const result = await handlers['routing.decide'](req) as GatewayResponse;
    const payload = result.result as {
      match: { rule: { name: string } };
      decision: { path: string };
    };

    expect(payload.match.rule.name).toBe('deploy-route');
    expect(payload.decision.path).toBe('fast');
  });
});

describe('history handlers', () => {
  it('history.search returns ranked results', async () => {
    const historySessionManager = asHistorySessionManager({
      searchHistory: () => [{ sessionId: 'ws:test', messageId: 1, role: 'user', content: 'deploy', score: 0.9, createdAt: 123 }],
      reindexHistory: () => 0,
    });
    const handlers = createHistoryHandlers({
      sessionManager: historySessionManager,
    });

    const req: GatewayRequest = { id: 13, method: 'history.search', params: { query: 'deploy' } };
    const result = await handlers['history.search'](req) as GatewayResponse;
    const payload = result.result as { results: Array<{ sessionId: string }> };
    expect(payload.results[0].sessionId).toBe('ws:test');
  });

  it('history.reindex returns count', async () => {
    const historySessionManager = asHistorySessionManager({
      searchHistory: () => [],
      reindexHistory: () => 42,
    });
    const handlers = createHistoryHandlers({
      sessionManager: historySessionManager,
    });

    const req: GatewayRequest = { id: 14, method: 'history.reindex' };
    const result = await handlers['history.reindex'](req) as GatewayResponse;
    expect((result.result as { reindexed: number }).reindexed).toBe(42);
  });
});

describe('system.restart handler', () => {
  it('returns restarting:true and calls restart callback', async () => {
    const restartFn = vi.fn(async () => {});
    const handlers = createSystemHandlers({
      startTime: Date.now(),
      version: '0.1.0',
      getSessionCount: () => 0,
      getToolCount: () => 0,
      getConnectionCount: () => 0,
      restart: restartFn,
    });

    const req: GatewayRequest = { id: 1, method: 'system.restart' };
    const result = await handlers['system.restart'](req) as GatewayResponse;

    expect(result.id).toBe(1);
    expect(getPath(result.result, 'restarting')).toBe(true);

    // Restart is called asynchronously via queueMicrotask
    await new Promise<void>((resolve) => queueMicrotask(resolve));
    expect(restartFn).toHaveBeenCalledOnce();
  });

  it('returns error when restart is not available', async () => {
    const handlers = createSystemHandlers({
      startTime: Date.now(),
      version: '0.1.0',
      getSessionCount: () => 0,
      getToolCount: () => 0,
      getConnectionCount: () => 0,
    });

    const req: GatewayRequest = { id: 2, method: 'system.restart' };
    const result = await handlers['system.restart'](req) as GatewayError;

    expect(result.error.code).toBe(ErrorCode.InternalError);
    expect(result.error.message).toContain('not available');
  });
});

describe('config handlers', () => {
  function makeConfig() {
    return {
      telegram: { bot_token: 'secret-token-123', allowed_chat_ids: [12345] },
      server: {
        tailscale: {},
        localhost: true,
        port: 18800,
        queue: {
          mode: 'collect' as const,
          cap: 50,
          overflow: 'drop_old' as const,
          debounce_ms: 0,
          summarize_overflow: true,
        },
        nodes: {
          enabled: false,
          allowed_roles: ['companion'],
          feature_gates: {},
          location: {
            enabled: false,
          },
          push: {
            enabled: false,
          },
        },
      },
      models: {
        default: { provider: 'anthropic' as const, model: 'claude-3-haiku', api_key: 'sk-secret-key' },
        fallback_chain: ['anthropic'],
      },
      backends: { claude_code: { enabled: false }, opencode: { enabled: false }, native: { enabled: true } },
      hooks: { confirm: ['shell.exec'], log: [], silent: [] },
      mcp: { servers: [] },
    };
  }

  it('config.get returns redacted config', async () => {
    const config = makeConfig();
    const handlers = createConfigHandlers({ config: asConfigValue(config) });
    const req: GatewayRequest = { id: 1, method: 'config.get' };
    const result = await handlers['config.get'](req) as GatewayResponse;

    expect(getPath(result.result, 'telegram', 'bot_token')).toBe('***');
    expect(getPath(result.result, 'models', 'default', 'api_key')).toBe('***');
    // Non-secret values are preserved
    expect(getPath(result.result, 'server', 'port')).toBe(18800);
    expect(getPath(result.result, 'hooks', 'confirm')).toEqual(['shell.exec']);
  });

  it('config.patch applies valid patches', async () => {
    const config = makeConfig();
    const handlers = createConfigHandlers({ config: asConfigValue(config) });
    const req: GatewayRequest = {
      id: 2,
      method: 'config.patch',
      params: {
        patches: {
          'hooks.confirm': ['shell.exec', 'file.write'],
          'hooks.log': ['file.read'],
          'server.queue.mode': 'followup',
          'server.queue.debounce_ms': 100,
          'server.nodes.location.enabled': true,
          'server.nodes.push.enabled': true,
        },
      },
    };
    const result = await handlers['config.patch'](req) as GatewayResponse;

    const r = result.result as { applied: string[]; rejected: string[]; persisted: boolean };
    expect(r.applied).toEqual(['hooks.confirm', 'hooks.log', 'server.queue.mode', 'server.queue.debounce_ms', 'server.nodes.location.enabled', 'server.nodes.push.enabled']);
    expect(r.rejected).toEqual([]);
    expect(r.persisted).toBe(false);
    // Verify the config was actually mutated
    expect(config.hooks.confirm).toEqual(['shell.exec', 'file.write']);
    expect(config.hooks.log).toEqual(['file.read']);
    expect(config.server.queue.mode).toBe('followup');
    expect(config.server.queue.debounce_ms).toBe(100);
    expect(config.server.nodes.location.enabled).toBe(true);
    expect(config.server.nodes.push.enabled).toBe(true);
  });

  it('config.patch rejects unknown keys', async () => {
    const config = makeConfig();
    const handlers = createConfigHandlers({ config: asConfigValue(config) });
    const req: GatewayRequest = {
      id: 3,
      method: 'config.patch',
      params: {
        patches: {
          'telegram.bot_token': 'hacked',
          'hooks.confirm': [],
        },
      },
    };
    const result = await handlers['config.patch'](req) as GatewayResponse;

    const r = result.result as { applied: string[]; rejected: string[]; persisted: boolean };
    expect(r.applied).toEqual(['hooks.confirm']);
    expect(r.rejected).toEqual(['telegram.bot_token']);
    expect(r.persisted).toBe(false);
  });

  it('config.patch rejects invalid value types', async () => {
    const config = makeConfig();
    const handlers = createConfigHandlers({ config: asConfigValue(config) });
    const req: GatewayRequest = {
      id: 4,
      method: 'config.patch',
      params: {
        patches: {
          'hooks.confirm': 'not-an-array',
          'server.queue.cap': 0,
        },
      },
    };
    const result = await handlers['config.patch'](req) as GatewayResponse;

    const r = result.result as { applied: string[]; rejected: string[]; persisted: boolean };
    expect(r.applied).toEqual([]);
    expect(r.rejected).toEqual(['hooks.confirm', 'server.queue.cap']);
    expect(r.persisted).toBe(false);
  });

  it('config.patch persists changes when persistence callback is provided', async () => {
    const config = makeConfig();
    const persist = vi.fn();
    const handlers = createConfigHandlers({
      config: asConfigValue(config),
      persistConfig: persist as () => Promise<void>,
    });
    const req: GatewayRequest = {
      id: 6,
      method: 'config.patch',
      params: { patches: { 'hooks.confirm': ['shell.exec', 'file.write'] } },
    };
    const result = await handlers['config.patch'](req) as GatewayResponse;
    const r = result.result as { applied: string[]; rejected: string[]; persisted: boolean };

    expect(r.applied).toEqual(['hooks.confirm']);
    expect(r.rejected).toEqual([]);
    expect(r.persisted).toBe(true);
    expect(persist).toHaveBeenCalledTimes(1);
    expect(config.hooks.confirm).toEqual(['shell.exec', 'file.write']);
  });

  it('config.patch does not mutate runtime config when persistence fails', async () => {
    const config = makeConfig();
    const before = [...config.hooks.confirm];
    const persist = vi.fn().mockRejectedValue(new Error('disk full'));
    const handlers = createConfigHandlers({
      config: asConfigValue(config),
      persistConfig: persist as () => Promise<void>,
    });
    const req: GatewayRequest = {
      id: 7,
      method: 'config.patch',
      params: { patches: { 'hooks.confirm': ['file.write'] } },
    };
    const result = await handlers['config.patch'](req) as GatewayResponse;
    const r = result.result as { applied: string[]; rejected: string[]; persisted: boolean; persistError?: string };

    expect(r.applied).toEqual([]);
    expect(r.rejected).toEqual([]);
    expect(r.persisted).toBe(false);
    expect(r.persistError).toContain('disk full');
    expect(config.hooks.confirm).toEqual(before);
  });

  it('config.patch requires patches object', async () => {
    const config = makeConfig();
    const handlers = createConfigHandlers({ config: asConfigValue(config) });
    const req: GatewayRequest = { id: 5, method: 'config.patch', params: {} };
    const result = await handlers['config.patch'](req) as GatewayError;

    expect(result.error.code).toBe(ErrorCode.InvalidRequest);
  });
});

describe('redactConfig – comprehensive credential redaction', () => {
  /**
   * Build a full config object with secrets in every possible location.
   * Optional sections (discord, slack, etc.) are included to test redaction.
   */
  function makeFullConfig() {
    return {
      telegram: { bot_token: 'tg-secret', allowed_chat_ids: [1], require_mention: true },
      discord: { bot_token: 'dc-secret', allowed_guild_ids: ['g1'], allowed_channel_ids: [], require_mention: true },
      slack: { bot_token: 'sl-bot', app_token: 'sl-app', signing_secret: 'sl-sign', allowed_channel_ids: [], require_mention: false },
      matrix: { homeserver_url: 'https://matrix.example.org', access_token: 'mx-secret', allowed_room_ids: ['!room1:example.org'], require_mention: true },
      mattermost: { server_url: 'https://mattermost.example.org', bot_token: 'mm-secret', allowed_channel_ids: [], require_mention: true, mention_name: 'flynn', poll_interval_ms: 3000 },
      server: { tailscale: {}, localhost: true, port: 18800, token: 'bearer-secret', tailscale_identity: false, auth_http: true },
      models: {
        default: { provider: 'anthropic' as const, model: 'claude', api_key: 'sk-def', auth_token: 'at-def',
          fallback: { provider: 'openai' as const, model: 'gpt-4', api_key: 'sk-def-fb', auth_token: 'at-def-fb' },
        },
        fast: { provider: 'openai' as const, model: 'gpt-4o-mini', api_key: 'sk-fast',
          fallback: { provider: 'gemini' as const, model: 'gemini-flash', api_key: 'sk-fast-fb' },
        },
        complex: { provider: 'anthropic' as const, model: 'claude-opus', auth_token: 'at-complex' },
        local: { provider: 'ollama' as const, model: 'llama3' },
        fallback_chain: ['anthropic'],
        local_providers: {
          ollama: { provider: 'ollama' as const, model: 'llama3', api_key: 'lp-key', auth_token: 'lp-token',
            fallback: { provider: 'llamacpp' as const, model: 'llama', api_key: 'lp-fb-key' },
          },
        },
        thinking: { anthropic: { budgetTokens: 4096 }, openai: { reasoningEffort: 'medium' as const }, gemini: { budgetTokens: 4096 } },
      },
      web_search: { provider: 'brave' as const, api_key: 'brave-key', endpoint: 'https://api.brave.com', max_results: 5 },
      audio: { transcription_endpoint: 'https://api.openai.com', transcription_api_key: 'audio-key', transcription_model: 'whisper-1' },
      memory: {
        enabled: true, auto_extract: true, max_context_tokens: 2000,
        embedding: { enabled: true, provider: 'openai' as const, model: 'text-embedding-3-small', api_key: 'embed-key', dimensions: 1536, chunk_size: 512, chunk_overlap: 50, top_k: 5, hybrid_weight: 0.7 },
      },
      automation: {
        cron: [],
        webhooks: [
          { name: 'github', secret: 'wh-secret-1', message: '{{body}}', output: { channel: 'telegram', peer: '123' }, enabled: true },
          { name: 'gitlab', secret: 'wh-secret-2', message: '{{body}}', output: { channel: 'telegram', peer: '456' }, enabled: true },
          { name: 'no-secret', message: '{{body}}', output: { channel: 'telegram', peer: '789' }, enabled: true },
        ],
        gmail: { enabled: true, credentials_file: '/path/to/creds.json', token_file: '/path/to/token.json', watch_labels: ['INBOX'], poll_interval: '300s', output: { channel: 'telegram', peer: '123' }, message: 'new email' },
        heartbeat: { enabled: false, interval: '5m', checks: ['gateway'], failure_threshold: 2, disk_threshold_mb: 100 },
      },
      mcp: {
        servers: [
          { name: 'my-server', command: 'node', args: ['server.js'], env: { API_KEY: 'mcp-api-key', DATABASE_URL: 'postgres://secret@host/db' } },
          { name: 'no-env', command: 'python', args: ['app.py'] },
        ],
      },
      hooks: { confirm: ['shell.exec'], log: [], silent: [] },
      backends: { claude_code: { enabled: false }, opencode: { enabled: false }, native: { enabled: true } },
    };
  }

  it('redacts telegram.bot_token', () => {
    const result = redactConfig(asRedactInput(makeFullConfig()));
    expect(getPath(result, 'telegram', 'bot_token')).toBe('***');
  });

  it('redacts discord.bot_token', () => {
    const result = redactConfig(asRedactInput(makeFullConfig()));
    expect(getPath(result, 'discord', 'bot_token')).toBe('***');
  });

  it('redacts slack.bot_token, app_token, and signing_secret', () => {
    const result = redactConfig(asRedactInput(makeFullConfig()));
    expect(getPath(result, 'slack', 'bot_token')).toBe('***');
    expect(getPath(result, 'slack', 'app_token')).toBe('***');
    expect(getPath(result, 'slack', 'signing_secret')).toBe('***');
  });

  it('redacts matrix.access_token', () => {
    const result = redactConfig(asRedactInput(makeFullConfig()));
    expect(getPath(result, 'matrix', 'access_token')).toBe('***');
  });

  it('redacts mattermost.bot_token', () => {
    const result = redactConfig(asRedactInput(makeFullConfig()));
    expect(getPath(result, 'mattermost', 'bot_token')).toBe('***');
  });

  it('redacts server.token', () => {
    const result = redactConfig(asRedactInput(makeFullConfig()));
    expect(getPath(result, 'server', 'token')).toBe('***');
  });

  it('redacts model api_key and auth_token for all tiers', () => {
    const result = redactConfig(asRedactInput(makeFullConfig()));
    expect(getPath(result, 'models', 'default', 'api_key')).toBe('***');
    expect(getPath(result, 'models', 'default', 'auth_token')).toBe('***');
    expect(getPath(result, 'models', 'fast', 'api_key')).toBe('***');
    expect(getPath(result, 'models', 'complex', 'auth_token')).toBe('***');
    // local has no keys — should remain unchanged
    expect(getPath(result, 'models', 'local', 'api_key')).toBeUndefined();
  });

  it('redacts model fallback api_key and auth_token', () => {
    const result = redactConfig(asRedactInput(makeFullConfig()));
    expect(getPath(result, 'models', 'default', 'fallback', 'api_key')).toBe('***');
    expect(getPath(result, 'models', 'default', 'fallback', 'auth_token')).toBe('***');
    expect(getPath(result, 'models', 'fast', 'fallback', 'api_key')).toBe('***');
  });

  it('redacts local_providers api_key, auth_token, and their fallbacks', () => {
    const result = redactConfig(asRedactInput(makeFullConfig()));
    expect(getPath(result, 'models', 'local_providers', 'ollama', 'api_key')).toBe('***');
    expect(getPath(result, 'models', 'local_providers', 'ollama', 'auth_token')).toBe('***');
    expect(getPath(result, 'models', 'local_providers', 'ollama', 'fallback', 'api_key')).toBe('***');
  });

  it('redacts web_search.api_key', () => {
    const result = redactConfig(asRedactInput(makeFullConfig()));
    expect(getPath(result, 'web_search', 'api_key')).toBe('***');
  });

  it('redacts audio.transcription_api_key', () => {
    const result = redactConfig(asRedactInput(makeFullConfig()));
    expect(getPath(result, 'audio', 'transcription_api_key')).toBe('***');
  });

  it('redacts memory.embedding.api_key', () => {
    const result = redactConfig(asRedactInput(makeFullConfig()));
    expect(getPath(result, 'memory', 'embedding', 'api_key')).toBe('***');
  });

  it('redacts automation webhook secrets', () => {
    const result = redactConfig(asRedactInput(makeFullConfig()));
    expect(getPath(result, 'automation', 'webhooks', '0', 'secret')).toBe('***');
    expect(getPath(result, 'automation', 'webhooks', '1', 'secret')).toBe('***');
    // Webhook without a secret should remain unaffected
    expect(getPath(result, 'automation', 'webhooks', '2', 'secret')).toBeUndefined();
  });

  it('redacts automation gmail credentials_file and token_file', () => {
    const result = redactConfig(asRedactInput(makeFullConfig()));
    expect(getPath(result, 'automation', 'gmail', 'credentials_file')).toBe('***');
    expect(getPath(result, 'automation', 'gmail', 'token_file')).toBe('***');
  });

  it('redacts all MCP server env vars', () => {
    const result = redactConfig(asRedactInput(makeFullConfig()));
    expect(getPath(result, 'mcp', 'servers', '0', 'env', 'API_KEY')).toBe('***');
    expect(getPath(result, 'mcp', 'servers', '0', 'env', 'DATABASE_URL')).toBe('***');
    // Server without env should be unaffected
    expect(getPath(result, 'mcp', 'servers', '1', 'env')).toBeUndefined();
  });

  it('preserves non-secret fields', () => {
    const result = redactConfig(asRedactInput(makeFullConfig()));

    // telegram
    expect(getPath(result, 'telegram', 'allowed_chat_ids')).toEqual([1]);
    expect(getPath(result, 'telegram', 'require_mention')).toBe(true);
    // discord
    expect(getPath(result, 'discord', 'allowed_guild_ids')).toEqual(['g1']);
    // slack
    expect(getPath(result, 'slack', 'allowed_channel_ids')).toEqual([]);
    // server
    expect(getPath(result, 'server', 'port')).toBe(18800);
    expect(getPath(result, 'server', 'tailscale')).toBeDefined();
    // models
    expect(getPath(result, 'models', 'default', 'provider')).toBe('anthropic');
    expect(getPath(result, 'models', 'default', 'model')).toBe('claude');
    expect(getPath(result, 'models', 'fallback_chain')).toEqual(['anthropic']);
    // web_search
    expect(getPath(result, 'web_search', 'provider')).toBe('brave');
    expect(getPath(result, 'web_search', 'max_results')).toBe(5);
    // audio
    expect(getPath(result, 'audio', 'transcription_model')).toBe('whisper-1');
    // memory
    expect(getPath(result, 'memory', 'embedding', 'model')).toBe('text-embedding-3-small');
    // hooks
    expect(getPath(result, 'hooks', 'confirm')).toEqual(['shell.exec']);
    // mcp
    expect(getPath(result, 'mcp', 'servers', '0', 'name')).toBe('my-server');
    expect(getPath(result, 'mcp', 'servers', '0', 'command')).toBe('node');
  });

  it('handles missing optional sections gracefully', () => {
    const minimal = {
      telegram: { bot_token: 'tok', allowed_chat_ids: [1] },
      models: { default: { provider: 'anthropic' as const, model: 'claude' }, fallback_chain: [] },
      server: { port: 18800 },
      hooks: { confirm: [], log: [], silent: [] },
    };
    // Should not throw even when discord, slack, automation, mcp, etc. are absent
    const result = redactConfig(asRedactInput(minimal));
    expect(getPath(result, 'telegram', 'bot_token')).toBe('***');
    expect(result.discord).toBeUndefined();
    expect(result.slack).toBeUndefined();
    expect(result.automation).toBeUndefined();
  });

  it('does not mutate the original config object', () => {
    const config = makeFullConfig();
    redactConfig(asRedactInput(config));
    // Original secrets should still be intact
    expect(config.telegram.bot_token).toBe('tg-secret');
    expect(config.models.default.api_key).toBe('sk-def');
    expect(config.server.token).toBe('bearer-secret');
  });
});

describe('pairing handlers', () => {
  let pm: PairingManager;
  let handlers: ReturnType<typeof createPairingHandlers>;

  beforeEach(() => {
    pm = new PairingManager({ enabled: true, codeTtl: 300_000, codeLength: 6 });
    handlers = createPairingHandlers({ pairingManager: pm });
  });

  it('pairing.generate returns a code and expiry', async () => {
    const req: GatewayRequest = { id: 1, method: 'pairing.generate', params: { label: 'for alice' } };
    const result = await handlers['pairing.generate'](req) as GatewayResponse;

    const r = result.result as { code: string; expiresAt: number };
    expect(r.code).toHaveLength(6);
    expect(r.expiresAt).toBeGreaterThan(Date.now());
  });

  it('pairing.generate works without label', async () => {
    const req: GatewayRequest = { id: 2, method: 'pairing.generate' };
    const result = await handlers['pairing.generate'](req) as GatewayResponse;

    const r = result.result as { code: string; expiresAt: number };
    expect(r.code).toHaveLength(6);
  });

  it('pairing.list returns pending codes and approved senders', async () => {
    // Generate a code first
    pm.generateCode('test');
    // Approve a sender
    const code = pm.generateCode('for bob');
    pm.validateCode('telegram', '12345', code);

    const req: GatewayRequest = { id: 3, method: 'pairing.list' };
    const result = await handlers['pairing.list'](req) as GatewayResponse;

    const r = result.result as { pending: unknown[]; approved: unknown[] };
    expect(r.pending).toHaveLength(1); // one code remaining (the other was consumed)
    expect(r.approved).toHaveLength(1);
  });

  it('pairing.revoke removes an approved sender', async () => {
    // Approve a sender
    const code = pm.generateCode();
    pm.validateCode('discord', 'chan-1', code);
    expect(pm.isApproved('discord', 'chan-1')).toBe(true);

    const req: GatewayRequest = { id: 4, method: 'pairing.revoke', params: { channel: 'discord', senderId: 'chan-1' } };
    const result = await handlers['pairing.revoke'](req) as GatewayResponse;

    const r = result.result as { revoked: boolean };
    expect(r.revoked).toBe(true);
    expect(pm.isApproved('discord', 'chan-1')).toBe(false);
  });

  it('pairing.revoke returns false for unknown sender', async () => {
    const req: GatewayRequest = { id: 5, method: 'pairing.revoke', params: { channel: 'telegram', senderId: 'unknown' } };
    const result = await handlers['pairing.revoke'](req) as GatewayResponse;

    const r = result.result as { revoked: boolean };
    expect(r.revoked).toBe(false);
  });

  it('pairing.revoke requires channel and senderId', async () => {
    const req: GatewayRequest = { id: 6, method: 'pairing.revoke', params: {} };
    const result = await handlers['pairing.revoke'](req) as GatewayError;

    expect(result.error.code).toBe(ErrorCode.InvalidRequest);
    expect(result.error.message).toContain('channel');
  });
});
