/**
 * Unit tests for SocketIOMCPTransportImpl
 *
 * The socket.io-client module is replaced with a lightweight in-process fake
 * so no real network is involved.
 */
import { describe, expect, it, vi } from 'vitest';

import { SocketIOMCPTransportImpl } from './transport';
import type { MCPRequest, MCPResponse } from './types';

// ---------------------------------------------------------------------------
// Minimal Socket fake
// ---------------------------------------------------------------------------

type EventHandler = (...args: unknown[]) => void;

function makeSocket(overrides: { connected?: boolean } = {}) {
  const handlers = new Map<string, EventHandler[]>();

  const socket = {
    connected: overrides.connected ?? true,
    emit: vi.fn(),
    on(event: string, handler: EventHandler) {
      if (!handlers.has(event)) handlers.set(event, []);
      handlers.get(event)!.push(handler);
    },
    off(event: string, handler: EventHandler) {
      const list = handlers.get(event) ?? [];
      const idx = list.indexOf(handler);
      if (idx !== -1) list.splice(idx, 1);
    },
    /** Test helper: trigger a registered handler */
    trigger(event: string, ...args: unknown[]) {
      for (const h of handlers.get(event) ?? []) {
        h(...args);
      }
    },
    _handlers: handlers,
  };

  return socket;
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

function makeRequest(id: string | number = 'req-1', method = 'test.method'): MCPRequest {
  return { jsonrpc: '2.0', id, method };
}

function makeResponse(id: string | number, result: unknown = { ok: true }): MCPResponse {
  return { jsonrpc: '2.0', id, result };
}

function makeErrorResponse(id: string | number, message = 'RPC error'): MCPResponse {
  return { jsonrpc: '2.0', id, error: { code: -32000, message } };
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

describe('SocketIOMCPTransportImpl — connected property', () => {
  it('returns true when the socket is connected', () => {
    const socket = makeSocket({ connected: true });
    const transport = new SocketIOMCPTransportImpl(socket as never);
    expect(transport.connected).toBe(true);
  });

  it('returns false when the socket is disconnected', () => {
    const socket = makeSocket({ connected: false });
    const transport = new SocketIOMCPTransportImpl(socket as never);
    expect(transport.connected).toBe(false);
  });

  it('returns false when socket is null', () => {
    const transport = new SocketIOMCPTransportImpl(null);
    expect(transport.connected).toBe(false);
  });
});

describe('SocketIOMCPTransportImpl — emit', () => {
  it('emits with the mcp: prefix when socket is connected', () => {
    const socket = makeSocket();
    const transport = new SocketIOMCPTransportImpl(socket as never);
    transport.emit('request', { id: 1 });
    expect(socket.emit).toHaveBeenCalledWith('mcp:request', { id: 1 });
  });

  it('does nothing when socket is disconnected', () => {
    const socket = makeSocket({ connected: false });
    const transport = new SocketIOMCPTransportImpl(socket as never);
    transport.emit('request', { id: 1 });
    expect(socket.emit).not.toHaveBeenCalled();
  });
});

describe('SocketIOMCPTransportImpl — on / off', () => {
  it('registers a handler on the prefixed event', () => {
    const socket = makeSocket();
    const transport = new SocketIOMCPTransportImpl(socket as never);
    const handler = vi.fn();
    transport.on('tool_call', handler);
    // Trigger via the raw socket
    socket.trigger('mcp:tool_call', { data: 42 });
    expect(handler).toHaveBeenCalledWith({ data: 42 });
  });

  it('off removes the handler by resolving the wrapped handler', () => {
    const socket = makeSocket();
    const transport = new SocketIOMCPTransportImpl(socket as never);
    const handler = vi.fn();

    transport.on('tool_call', handler);
    // Trigger via the raw socket to ensure it works
    socket.trigger('mcp:tool_call', { data: 42 });
    expect(handler).toHaveBeenCalledWith({ data: 42 });
    expect(handler).toHaveBeenCalledTimes(1);

    transport.off('tool_call', handler);
    // Trigger again, the handler should NOT be called
    socket.trigger('mcp:tool_call', { data: 84 });
    expect(handler).toHaveBeenCalledTimes(1); // Still 1
  });
});

describe('SocketIOMCPTransportImpl — request / response routing', () => {
  it('resolves when a matching response arrives', async () => {
    const socket = makeSocket();
    const transport = new SocketIOMCPTransportImpl(socket as never);
    const req = makeRequest('abc');

    const promise = transport.request(req);
    // Simulate the backend replying
    socket.trigger('mcp:response', makeResponse('abc'));

    const response = await promise;
    expect(response.result).toEqual({ ok: true });
    expect(response.id).toBe('abc');
  });

  it('rejects when the response contains an error', async () => {
    const socket = makeSocket();
    const transport = new SocketIOMCPTransportImpl(socket as never);
    const req = makeRequest('err-1');

    const promise = transport.request(req);
    socket.trigger('mcp:response', makeErrorResponse('err-1', 'Not found'));

    await expect(promise).rejects.toThrow('Not found');
  });

  it('rejects with a timeout error when no response arrives within timeoutMs', async () => {
    vi.useFakeTimers();
    const socket = makeSocket();
    const transport = new SocketIOMCPTransportImpl(socket as never);
    const req = makeRequest('timeout-1');

    const promise = transport.request(req, 1000);
    vi.advanceTimersByTime(1001);

    await expect(promise).rejects.toThrow(/timeout/i);
    vi.useRealTimers();
  });

  it('cleans up the handler after a successful response', async () => {
    const socket = makeSocket();
    const transport = new SocketIOMCPTransportImpl(socket as never);
    const req = makeRequest('clean-1');

    const promise = transport.request(req);
    socket.trigger('mcp:response', makeResponse('clean-1'));
    await promise;

    // A second response with the same id should be ignored (no handler left)
    // We verify no throw occurs — the response handler logs a warn and returns.
    expect(() => socket.trigger('mcp:response', makeResponse('clean-1'))).not.toThrow();
  });

  it('rejects immediately when the socket is not connected', async () => {
    const socket = makeSocket({ connected: false });
    const transport = new SocketIOMCPTransportImpl(socket as never);
    await expect(transport.request(makeRequest())).rejects.toThrow('Socket not connected');
  });

  it('routes concurrent requests independently by id', async () => {
    const socket = makeSocket();
    const transport = new SocketIOMCPTransportImpl(socket as never);

    const p1 = transport.request(makeRequest('r1'));
    const p2 = transport.request(makeRequest('r2'));

    socket.trigger('mcp:response', makeResponse('r2', { data: 'second' }));
    socket.trigger('mcp:response', makeResponse('r1', { data: 'first' }));

    const [res1, res2] = await Promise.all([p1, p2]);
    expect((res1.result as { data: string }).data).toBe('first');
    expect((res2.result as { data: string }).data).toBe('second');
  });
});

describe('SocketIOMCPTransportImpl — updateSocket', () => {
  it('switches to the new socket and removes old listeners', () => {
    const socket1 = makeSocket();
    const socket2 = makeSocket();
    const transport = new SocketIOMCPTransportImpl(socket1 as never);

    transport.updateSocket(socket2 as never);

    // Old socket should no longer have the mcp:response listener.
    const oldHandlers = socket1._handlers.get('mcp:response') ?? [];
    expect(oldHandlers).toHaveLength(0);

    // New socket should have it.
    const newHandlers = socket2._handlers.get('mcp:response') ?? [];
    expect(newHandlers.length).toBeGreaterThan(0);
  });

  it('sets socket to undefined when called with null', () => {
    const socket = makeSocket();
    const transport = new SocketIOMCPTransportImpl(socket as never);
    transport.updateSocket(null);
    expect(transport.connected).toBe(false);
  });
});
