import http from 'node:http';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
import { describe, expect, it, vi } from 'vitest';
import type { ServerDefinition } from '../src/config.js';
import { createKeepAliveRuntime } from '../src/daemon/runtime-wrapper.js';
import type { Runtime } from '../src/runtime.js';
import { createBridgeServer, decodeToolName, encodeToolName, selectServedServers, serveHttp } from '../src/serve.js';

const definitions: ServerDefinition[] = [
  {
    name: 'alpha',
    description: 'keep alive server',
    command: { kind: 'http', url: new URL('https://alpha.example.com') },
    lifecycle: { mode: 'keep-alive' },
    source: { kind: 'local', path: '/tmp' },
  },
  {
    name: 'alpha-long',
    description: 'keep alive server',
    command: { kind: 'http', url: new URL('https://alpha-long.example.com') },
    lifecycle: { mode: 'keep-alive' },
    source: { kind: 'local', path: '/tmp' },
  },
  {
    name: 'beta',
    description: 'ephemeral server',
    command: { kind: 'http', url: new URL('https://beta.example.com') },
    source: { kind: 'local', path: '/tmp' },
  },
];

describe('mcporter serve bridge', () => {
  it('selects only keep-alive servers and validates explicit filters', () => {
    expect(selectServedServers(definitions).map((entry) => entry.name)).toEqual(['alpha', 'alpha-long']);
    expect(selectServedServers(definitions, ['alpha']).map((entry) => entry.name)).toEqual(['alpha']);
    expect(() => selectServedServers(definitions, ['beta'])).toThrow("Server 'beta' is not configured for keep-alive");
  });

  it('encodes and decodes namespaced tool names with longest-prefix matching', () => {
    expect(encodeToolName('alpha', 'ping')).toBe('alpha__ping');
    expect(decodeToolName('alpha-long__tool%5F%5Fwith_separator', [{ name: 'alpha' }, { name: 'alpha-long' }])).toEqual(
      {
        server: 'alpha-long',
        tool: 'tool__with_separator',
      }
    );
  });

  it('escapes namespaced tool parts to avoid server/tool collisions', () => {
    const first = encodeToolName('alpha', 'beta__ping');
    const second = encodeToolName('alpha__beta', 'ping');

    expect(first).toBe('alpha__beta%5F%5Fping');
    expect(second).toBe('alpha%5F%5Fbeta__ping');
    expect(first).not.toBe(second);
    expect(decodeToolName(first, [{ name: 'alpha' }, { name: 'alpha__beta' }])).toEqual({
      server: 'alpha',
      tool: 'beta__ping',
    });
    expect(decodeToolName(second, [{ name: 'alpha' }, { name: 'alpha__beta' }])).toEqual({
      server: 'alpha__beta',
      tool: 'ping',
    });
  });

  it('preserves ordinary underscores in server and tool names', () => {
    expect(encodeToolName('memory', 'create_entities')).toBe('memory__create_entities');
    expect(encodeToolName('alpha_', '_ping')).toBe('alpha%5F__%5Fping');
    expect(decodeToolName('alpha%5F__%5Fping', [{ name: 'alpha' }, { name: 'alpha_' }])).toEqual({
      server: 'alpha_',
      tool: '_ping',
    });
  });

  it('escapes boundary underscores so namespaced tools stay injective', () => {
    const first = encodeToolName('alpha', '_ping');
    const second = encodeToolName('alpha_', 'ping');

    expect(first).toBe('alpha__%5Fping');
    expect(second).toBe('alpha%5F__ping');
    expect(first).not.toBe(second);
    expect(decodeToolName(first, [{ name: 'alpha' }, { name: 'alpha_' }])).toEqual({
      server: 'alpha',
      tool: '_ping',
    });
    expect(decodeToolName(second, [{ name: 'alpha' }, { name: 'alpha_' }])).toEqual({
      server: 'alpha_',
      tool: 'ping',
    });
  });

  it('exposes daemon tools through a single MCP server', async () => {
    const runtime = {
      listTools: vi.fn().mockImplementation(async (server: string) => [
        {
          name: 'ping',
          description: `${server} ping`,
          inputSchema: {
            type: 'object',
            properties: { value: { type: 'number' } },
            required: ['value'],
          },
        },
      ]),
      callTool: vi.fn().mockResolvedValue({
        content: [{ type: 'text', text: 'pong' }],
      }),
    };
    const bridge = createBridgeServer({
      runtime,
      definitions,
      servers: ['alpha'],
    });
    const client = new Client({ name: 'test-client', version: '1.0.0' });
    const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
    await Promise.all([bridge.connect(serverTransport), client.connect(clientTransport)]);

    const tools = await client.listTools();
    expect(tools.tools).toEqual([
      expect.objectContaining({
        name: 'alpha__ping',
        description: '[alpha] alpha ping',
        inputSchema: expect.objectContaining({ required: ['value'] }),
      }),
    ]);
    expect(runtime.listTools).toHaveBeenCalledWith('alpha', {
      includeSchema: true,
      autoAuthorize: true,
    });

    await expect(client.callTool({ name: 'alpha__ping', arguments: { value: 1 } })).resolves.toEqual({
      content: [{ type: 'text', text: 'pong' }],
    });
    expect(runtime.callTool).toHaveBeenCalledWith('alpha', 'ping', {
      args: { value: 1 },
    });

    await client.close();
    await bridge.close();
  });

  it('routes bridged keep-alive tool traffic through the daemon runtime wrapper', async () => {
    const baseRuntime = {
      listServers: () => definitions.map((definition) => definition.name),
      getDefinitions: () => definitions,
      getDefinition: (server: string) => {
        const definition = definitions.find((entry) => entry.name === server);
        if (!definition) {
          throw new Error(`Unknown server ${server}`);
        }
        return definition;
      },
      registerDefinition: vi.fn(),
      listTools: vi.fn().mockResolvedValue([{ name: 'local-tool' }]),
      callTool: vi.fn().mockResolvedValue({ content: [{ type: 'text', text: 'local' }] }),
      listResources: vi.fn(),
      readResource: vi.fn(),
      connect: vi.fn(),
      close: vi.fn().mockResolvedValue(undefined),
    } satisfies Runtime;
    const daemon = {
      listTools: vi.fn().mockResolvedValue([
        {
          name: 'ping',
          description: 'daemon ping',
          inputSchema: { type: 'object' },
        },
      ]),
      callTool: vi
        .fn()
        .mockRejectedValueOnce(new Error('daemon transport died'))
        .mockResolvedValueOnce({
          content: [{ type: 'text', text: 'daemon pong' }],
        }),
      listResources: vi.fn(),
      readResource: vi.fn(),
      closeServer: vi.fn().mockResolvedValue(undefined),
    };
    const runtime = createKeepAliveRuntime(baseRuntime, {
      daemonClient: daemon as never,
      keepAliveServers: new Set(['alpha']),
    });
    const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
    const bridge = createBridgeServer({
      runtime,
      definitions,
      servers: ['alpha'],
    });
    const client = new Client({ name: 'daemon-wrapper-client', version: '1.0.0' });
    const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();

    try {
      await Promise.all([bridge.connect(serverTransport), client.connect(clientTransport)]);

      await expect(client.listTools()).resolves.toMatchObject({
        tools: [{ name: 'alpha__ping', description: '[alpha] daemon ping' }],
      });
      expect(daemon.listTools).toHaveBeenCalledWith({
        server: 'alpha',
        includeSchema: true,
        autoAuthorize: true,
        allowCachedAuth: true,
      });
      expect(baseRuntime.listTools).not.toHaveBeenCalled();

      await expect(client.callTool({ name: 'alpha__ping', arguments: {} })).resolves.toEqual({
        content: [{ type: 'text', text: 'daemon pong' }],
      });
      expect(daemon.callTool).toHaveBeenCalledTimes(2);
      expect(daemon.closeServer).toHaveBeenCalledWith({ server: 'alpha' });
      expect(baseRuntime.callTool).not.toHaveBeenCalled();
      expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Restarting 'alpha'"));
    } finally {
      errorSpy.mockRestore();
      await client.close().catch(() => {});
      await bridge.close().catch(() => {});
    }
  });

  it('serves the bridge over Streamable HTTP', async () => {
    const runtime = {
      listTools: vi.fn().mockResolvedValue([
        {
          name: 'ping',
          inputSchema: { type: 'object' },
        },
      ]),
      callTool: vi.fn().mockResolvedValue({
        content: [{ type: 'text', text: 'pong-http' }],
      }),
    };
    const httpServer = await serveHttp({
      runtime,
      definitions,
      servers: ['alpha'],
      port: 0,
    });
    const address = httpServer.address();
    if (!address || typeof address !== 'object') {
      throw new Error('Expected test HTTP server to listen on a TCP port.');
    }
    expect(address.address).toBe('127.0.0.1');

    const client = new Client({ name: 'test-http-client', version: '1.0.0' });
    const transport = new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${address.port}/mcp`));
    try {
      await client.connect(transport);
      const tools = await client.listTools();
      expect(tools.tools.map((tool) => tool.name)).toEqual(['alpha__ping']);
      await expect(client.callTool({ name: 'alpha__ping', arguments: {} })).resolves.toEqual({
        content: [{ type: 'text', text: 'pong-http' }],
      });
    } finally {
      await client.close().catch(() => {});
      await new Promise<void>((resolve, reject) => {
        httpServer.close((error) => {
          if (error) {
            reject(error);
            return;
          }
          resolve();
        });
      });
    }
  });

  it('returns 404 for paths outside the MCP endpoint', async () => {
    const runtime = {
      listTools: vi.fn(),
      callTool: vi.fn(),
    };
    const httpServer = await serveHttp({
      runtime,
      definitions,
      servers: ['alpha'],
      port: 0,
    });
    const address = httpServer.address();
    if (!address || typeof address !== 'object') {
      throw new Error('Expected test HTTP server to listen on a TCP port.');
    }
    try {
      const response = await fetch(`http://127.0.0.1:${address.port}/mcp-extra`);
      expect(response.status).toBe(404);
    } finally {
      await new Promise<void>((resolve, reject) => {
        httpServer.close((error) => {
          if (error) {
            reject(error);
            return;
          }
          resolve();
        });
      });
    }
  });

  it('exposes a single server unprefixed in bare mode', async () => {
    const runtime = {
      listTools: vi
        .fn()
        .mockImplementation(async (server: string) => [
          { name: 'ping', description: `${server} ping`, inputSchema: { type: 'object' } },
        ]),
      callTool: vi.fn().mockResolvedValue({ content: [{ type: 'text', text: 'pong' }] }),
    };
    const bridge = createBridgeServer({ runtime, definitions, servers: ['alpha'], bare: true });
    const client = new Client({ name: 'test-client', version: '1.0.0' });
    const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
    await Promise.all([bridge.connect(serverTransport), client.connect(clientTransport)]);

    const tools = await client.listTools();
    expect(tools.tools).toEqual([expect.objectContaining({ name: 'ping', description: 'alpha ping' })]);
    await expect(client.callTool({ name: 'ping', arguments: { value: 1 } })).resolves.toEqual({
      content: [{ type: 'text', text: 'pong' }],
    });
    expect(runtime.callTool).toHaveBeenCalledWith('alpha', 'ping', { args: { value: 1 } });

    await client.close();
    await bridge.close();
  });

  it('rejects bare mode unless exactly one server is served', () => {
    const runtime = { listTools: vi.fn(), callTool: vi.fn() };
    expect(() => createBridgeServer({ runtime, definitions, bare: true })).toThrow(
      'Bare serve mode requires exactly one served server.'
    );
  });

  it('serves a single server unprefixed over /mcp/<server> without changing aggregate /mcp', async () => {
    const runtime = {
      listTools: vi.fn().mockResolvedValue([{ name: 'ping', inputSchema: { type: 'object' } }]),
      callTool: vi.fn().mockResolvedValue({ content: [{ type: 'text', text: 'pong-http' }] }),
    };
    const httpServer = await serveHttp({ runtime, definitions, servers: ['alpha'], port: 0 });
    const address = httpServer.address();
    if (!address || typeof address !== 'object') {
      throw new Error('Expected test HTTP server to listen on a TCP port.');
    }
    const perServerClient = new Client({ name: 'test-http-client', version: '1.0.0' });
    const perServerTransport = new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${address.port}/mcp/alpha`));
    const aggregateClient = new Client({ name: 'test-http-aggregate-client', version: '1.0.0' });
    const aggregateTransport = new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${address.port}/mcp`));
    try {
      await perServerClient.connect(perServerTransport);
      const perServerTools = await perServerClient.listTools();
      expect(perServerTools.tools.map((tool) => tool.name)).toEqual(['ping']);
      await expect(perServerClient.callTool({ name: 'ping', arguments: {} })).resolves.toEqual({
        content: [{ type: 'text', text: 'pong-http' }],
      });

      await aggregateClient.connect(aggregateTransport);
      const aggregateTools = await aggregateClient.listTools();
      expect(aggregateTools.tools.map((tool) => tool.name)).toEqual(['alpha__ping']);
      await expect(aggregateClient.callTool({ name: 'alpha__ping', arguments: {} })).resolves.toEqual({
        content: [{ type: 'text', text: 'pong-http' }],
      });

      const unknown = await fetch(`http://127.0.0.1:${address.port}/mcp/nope`);
      expect(unknown.status).toBe(404);
      expect(unknown.headers.get('content-type')).toBe('text/plain; charset=utf-8');
      expect(await unknown.text()).toBe("Unknown server 'nope'");
    } finally {
      await perServerClient.close().catch(() => {});
      await aggregateClient.close().catch(() => {});
      await new Promise<void>((resolve, reject) => {
        httpServer.close((error) => {
          if (error) {
            reject(error);
            return;
          }
          resolve();
        });
      });
    }
  });

  it('returns 400 for a malformed percent-encoded server path', async () => {
    const runtime = { listTools: vi.fn(), callTool: vi.fn() };
    const httpServer = await serveHttp({ runtime, definitions, servers: ['alpha'], port: 0 });
    const address = httpServer.address();
    if (!address || typeof address !== 'object') {
      throw new Error('Expected test HTTP server to listen on a TCP port.');
    }
    try {
      const status = await new Promise<number>((resolve, reject) => {
        const req = http.request(
          { host: '127.0.0.1', port: address.port, path: '/mcp/%E0%A4%A', method: 'GET' },
          (res) => {
            res.resume();
            resolve(res.statusCode ?? 0);
          }
        );
        req.on('error', reject);
        req.end();
      });
      expect(status).toBe(400);
    } finally {
      await new Promise<void>((resolve, reject) => {
        httpServer.close((error) => {
          if (error) {
            reject(error);
            return;
          }
          resolve();
        });
      });
    }
  });
});
