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 { createConfigHandlers, redactConfig } from './config.js';
import { createPairingHandlers } from './pairing.js';
import { PairingManager } from '../../channels/pairing.js';
import { LaneQueue } from '../lane-queue.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';

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((result.result as any).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 },
      ] as any),
    });

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

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('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: mockSessionManager as any,
  });

  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('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: mockRegistry as any,
    toolExecutor: mockExecutor as any,
  });

  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'),
    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: mockBridge as any,
    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((doneEvent.data as any).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((errorEvent.data as any).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((result.result as any).cancelled).toBe(true);
    expect((result.result as any).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((result.result as any).cancelled).toBe(false);
    expect((result.result as any).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 handlers = createHistoryHandlers({
      sessionManager: {
        searchHistory: () => [{ sessionId: 'ws:test', messageId: 1, role: 'user', content: 'deploy', score: 0.9, createdAt: 123 }],
        reindexHistory: () => 0,
      } as any,
    });

    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 handlers = createHistoryHandlers({
      sessionManager: {
        searchHistory: () => [],
        reindexHistory: () => 42,
      } as any,
    });

    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((result.result as any).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 },
      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: config as any });
    const req: GatewayRequest = { id: 1, method: 'config.get' };
    const result = await handlers['config.get'](req) as GatewayResponse;

    const r = result.result as Record<string, any>;
    expect(r.telegram.bot_token).toBe('***');
    expect(r.models.default.api_key).toBe('***');
    // Non-secret values are preserved
    expect(r.server.port).toBe(18800);
    expect(r.hooks.confirm).toEqual(['shell.exec']);
  });

  it('config.patch applies valid patches', async () => {
    const config = makeConfig();
    const handlers = createConfigHandlers({ config: config as any });
    const req: GatewayRequest = {
      id: 2,
      method: 'config.patch',
      params: {
        patches: {
          'hooks.confirm': ['shell.exec', 'file.write'],
          'hooks.log': ['file.read'],
        },
      },
    };
    const result = await handlers['config.patch'](req) as GatewayResponse;

    const r = result.result as { applied: string[]; rejected: string[] };
    expect(r.applied).toEqual(['hooks.confirm', 'hooks.log']);
    expect(r.rejected).toEqual([]);
    // Verify the config was actually mutated
    expect(config.hooks.confirm).toEqual(['shell.exec', 'file.write']);
    expect(config.hooks.log).toEqual(['file.read']);
  });

  it('config.patch rejects unknown keys', async () => {
    const config = makeConfig();
    const handlers = createConfigHandlers({ config: config as any });
    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[] };
    expect(r.applied).toEqual(['hooks.confirm']);
    expect(r.rejected).toEqual(['telegram.bot_token']);
  });

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

    const r = result.result as { applied: string[]; rejected: string[] };
    expect(r.applied).toEqual([]);
    expect(r.rejected).toEqual(['hooks.confirm']);
  });

  it('config.patch requires patches object', async () => {
    const config = makeConfig();
    const handlers = createConfigHandlers({ config: config as any });
    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 },
      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(makeFullConfig() as any);
    expect((result.telegram as any).bot_token).toBe('***');
  });

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

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

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

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

  it('redacts model api_key and auth_token for all tiers', () => {
    const result = redactConfig(makeFullConfig() as any);
    const models = result.models as any;

    expect(models.default.api_key).toBe('***');
    expect(models.default.auth_token).toBe('***');
    expect(models.fast.api_key).toBe('***');
    expect(models.complex.auth_token).toBe('***');
    // local has no keys — should remain unchanged
    expect(models.local.api_key).toBeUndefined();
  });

  it('redacts model fallback api_key and auth_token', () => {
    const result = redactConfig(makeFullConfig() as any);
    const models = result.models as any;

    expect(models.default.fallback.api_key).toBe('***');
    expect(models.default.fallback.auth_token).toBe('***');
    expect(models.fast.fallback.api_key).toBe('***');
  });

  it('redacts local_providers api_key, auth_token, and their fallbacks', () => {
    const result = redactConfig(makeFullConfig() as any);
    const ollama = (result.models as any).local_providers.ollama;

    expect(ollama.api_key).toBe('***');
    expect(ollama.auth_token).toBe('***');
    expect(ollama.fallback.api_key).toBe('***');
  });

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

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

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

  it('redacts automation webhook secrets', () => {
    const result = redactConfig(makeFullConfig() as any);
    const webhooks = (result.automation as any).webhooks;

    expect(webhooks[0].secret).toBe('***');
    expect(webhooks[1].secret).toBe('***');
    // Webhook without a secret should remain unaffected
    expect(webhooks[2].secret).toBeUndefined();
  });

  it('redacts automation gmail credentials_file and token_file', () => {
    const result = redactConfig(makeFullConfig() as any);
    const gmail = (result.automation as any).gmail;

    expect(gmail.credentials_file).toBe('***');
    expect(gmail.token_file).toBe('***');
  });

  it('redacts all MCP server env vars', () => {
    const result = redactConfig(makeFullConfig() as any);
    const servers = (result.mcp as any).servers;

    expect(servers[0].env.API_KEY).toBe('***');
    expect(servers[0].env.DATABASE_URL).toBe('***');
    // Server without env should be unaffected
    expect(servers[1].env).toBeUndefined();
  });

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

    // telegram
    expect((result.telegram as any).allowed_chat_ids).toEqual([1]);
    expect((result.telegram as any).require_mention).toBe(true);
    // discord
    expect((result.discord as any).allowed_guild_ids).toEqual(['g1']);
    // slack
    expect((result.slack as any).allowed_channel_ids).toEqual([]);
    // server
    expect((result.server as any).port).toBe(18800);
    expect((result.server as any).tailscale).toBeDefined();
    // models
    expect((result.models as any).default.provider).toBe('anthropic');
    expect((result.models as any).default.model).toBe('claude');
    expect((result.models as any).fallback_chain).toEqual(['anthropic']);
    // web_search
    expect((result.web_search as any).provider).toBe('brave');
    expect((result.web_search as any).max_results).toBe(5);
    // audio
    expect((result.audio as any).transcription_model).toBe('whisper-1');
    // memory
    expect((result.memory as any).embedding.model).toBe('text-embedding-3-small');
    // hooks
    expect((result.hooks as any).confirm).toEqual(['shell.exec']);
    // mcp
    expect((result.mcp as any).servers[0].name).toBe('my-server');
    expect((result.mcp as any).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(minimal as any);
    expect((result.telegram as any).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(config as any);
    // 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');
  });
});
