const { Constants } = require('librechat-data-provider');
const { getCachedTools, getAppConfig } = require('~/server/services/Config');
const { getLogStores } = require('~/cache');

jest.mock('@librechat/data-schemas', () => ({
  logger: {
    debug: jest.fn(),
    error: jest.fn(),
    warn: jest.fn(),
  },
}));

jest.mock('~/server/services/Config', () => ({
  getCachedTools: jest.fn(),
  getAppConfig: jest.fn().mockResolvedValue({
    filteredTools: [],
    includedTools: [],
  }),
  setCachedTools: jest.fn(),
  mergeUserTools: jest.fn(),
}));

// loadAndFormatTools mock removed - no longer used in PluginController

jest.mock('~/config', () => ({
  getMCPManager: jest.fn(() => ({
    getAllToolFunctions: jest.fn().mockResolvedValue({}),
    getRawConfig: jest.fn().mockReturnValue({}),
  })),
  getFlowStateManager: jest.fn(),
}));

jest.mock('~/app/clients/tools', () => ({
  availableTools: [],
  toolkits: [],
}));

jest.mock('~/cache', () => ({
  getLogStores: jest.fn(),
}));

const { getAvailableTools, getAvailablePluginsController } = require('./PluginController');

describe('PluginController', () => {
  let mockReq, mockRes, mockCache;

  beforeEach(() => {
    jest.clearAllMocks();
    mockReq = {
      user: { id: 'test-user-id' },
      config: {
        filteredTools: [],
        includedTools: [],
      },
    };
    mockRes = { status: jest.fn().mockReturnThis(), json: jest.fn() };
    mockCache = { get: jest.fn(), set: jest.fn() };
    getLogStores.mockReturnValue(mockCache);

    // Clear availableTools and toolkits arrays before each test
    require('~/app/clients/tools').availableTools.length = 0;
    require('~/app/clients/tools').toolkits.length = 0;

    // Reset getCachedTools mock to ensure clean state
    getCachedTools.mockReset();

    // Reset getAppConfig mock to ensure clean state with default values
    getAppConfig.mockReset();
    getAppConfig.mockResolvedValue({
      filteredTools: [],
      includedTools: [],
    });
  });

  describe('getAvailablePluginsController', () => {
    it('should use filterUniquePlugins to remove duplicate plugins', async () => {
      // Add plugins with duplicates to availableTools
      const mockPlugins = [
        { name: 'Plugin1', pluginKey: 'key1', description: 'First' },
        { name: 'Plugin1', pluginKey: 'key1', description: 'First duplicate' },
        { name: 'Plugin2', pluginKey: 'key2', description: 'Second' },
      ];

      require('~/app/clients/tools').availableTools.push(...mockPlugins);

      mockCache.get.mockResolvedValue(null);

      // Configure getAppConfig to return the expected config
      getAppConfig.mockResolvedValueOnce({
        filteredTools: [],
        includedTools: [],
      });

      await getAvailablePluginsController(mockReq, mockRes);

      expect(mockRes.status).toHaveBeenCalledWith(200);
      const responseData = mockRes.json.mock.calls[0][0];
      // The real filterUniquePlugins should have removed the duplicate
      expect(responseData).toHaveLength(2);
      expect(responseData[0].pluginKey).toBe('key1');
      expect(responseData[1].pluginKey).toBe('key2');
    });

    it('should use checkPluginAuth to verify plugin authentication', async () => {
      // checkPluginAuth returns false for plugins without authConfig
      // so authenticated property won't be added
      const mockPlugin = { name: 'Plugin1', pluginKey: 'key1', description: 'First' };

      require('~/app/clients/tools').availableTools.push(mockPlugin);
      mockCache.get.mockResolvedValue(null);

      // Configure getAppConfig to return the expected config
      getAppConfig.mockResolvedValueOnce({
        filteredTools: [],
        includedTools: [],
      });

      await getAvailablePluginsController(mockReq, mockRes);

      const responseData = mockRes.json.mock.calls[0][0];
      // The real checkPluginAuth returns false for plugins without authConfig, so authenticated property is not added
      expect(responseData[0].authenticated).toBeUndefined();
    });

    it('should return cached plugins when available', async () => {
      const cachedPlugins = [
        { name: 'CachedPlugin', pluginKey: 'cached', description: 'Cached plugin' },
      ];

      mockCache.get.mockResolvedValue(cachedPlugins);

      await getAvailablePluginsController(mockReq, mockRes);

      // When cache is hit, we return immediately without processing
      expect(mockRes.json).toHaveBeenCalledWith(cachedPlugins);
    });

    it('should filter plugins based on includedTools', async () => {
      const mockPlugins = [
        { name: 'Plugin1', pluginKey: 'key1', description: 'First' },
        { name: 'Plugin2', pluginKey: 'key2', description: 'Second' },
      ];

      require('~/app/clients/tools').availableTools.push(...mockPlugins);
      mockCache.get.mockResolvedValue(null);

      // Configure getAppConfig to return config with includedTools
      getAppConfig.mockResolvedValueOnce({
        filteredTools: [],
        includedTools: ['key1'],
      });

      await getAvailablePluginsController(mockReq, mockRes);

      const responseData = mockRes.json.mock.calls[0][0];
      expect(responseData).toHaveLength(1);
      expect(responseData[0].pluginKey).toBe('key1');
    });
  });

  describe('getAvailableTools', () => {
    it('should use convertMCPToolsToPlugins for user-specific MCP tools', async () => {
      const mockUserTools = {
        [`tool1${Constants.mcp_delimiter}server1`]: {
          type: 'function',
          function: {
            name: `tool1${Constants.mcp_delimiter}server1`,
            description: 'Tool 1',
            parameters: { type: 'object', properties: {} },
          },
        },
      };

      mockCache.get.mockResolvedValue(null);
      getCachedTools.mockResolvedValueOnce(mockUserTools);
      mockReq.config = {
        mcpConfig: {
          server1: {},
        },
        paths: { structuredTools: '/mock/path' },
      };

      // Mock MCP manager to return empty tools initially (since getAllToolFunctions is called)
      const mockMCPManager = {
        getAllToolFunctions: jest.fn().mockResolvedValue({}),
        getRawConfig: jest.fn().mockReturnValue({}),
      };
      require('~/config').getMCPManager.mockReturnValue(mockMCPManager);

      // Mock second call to return tool definitions (includeGlobal: true)
      getCachedTools.mockResolvedValueOnce(mockUserTools);

      await getAvailableTools(mockReq, mockRes);

      expect(mockRes.status).toHaveBeenCalledWith(200);
      const responseData = mockRes.json.mock.calls[0][0];
      expect(responseData).toBeDefined();
      expect(Array.isArray(responseData)).toBe(true);
      expect(responseData.length).toBeGreaterThan(0);
      const convertedTool = responseData.find(
        (tool) => tool.pluginKey === `tool1${Constants.mcp_delimiter}server1`,
      );
      expect(convertedTool).toBeDefined();
      // The real convertMCPToolsToPlugins extracts the name from the delimiter
      expect(convertedTool.name).toBe('tool1');
    });

    it('should use filterUniquePlugins to deduplicate combined tools', async () => {
      const mockUserTools = {
        'user-tool': {
          type: 'function',
          function: {
            name: 'user-tool',
            description: 'User tool',
            parameters: { type: 'object', properties: {} },
          },
        },
      };

      const mockCachedPlugins = [
        { name: 'user-tool', pluginKey: 'user-tool', description: 'Duplicate user tool' },
        { name: 'ManifestTool', pluginKey: 'manifest-tool', description: 'Manifest tool' },
      ];

      mockCache.get.mockResolvedValue(mockCachedPlugins);
      getCachedTools.mockResolvedValueOnce(mockUserTools);
      mockReq.config = {
        mcpConfig: null,
        paths: { structuredTools: '/mock/path' },
      };

      // Mock second call to return tool definitions
      getCachedTools.mockResolvedValueOnce(mockUserTools);

      await getAvailableTools(mockReq, mockRes);

      expect(mockRes.status).toHaveBeenCalledWith(200);
      const responseData = mockRes.json.mock.calls[0][0];
      expect(Array.isArray(responseData)).toBe(true);
      // The real filterUniquePlugins should have deduplicated tools with same pluginKey
      const userToolCount = responseData.filter((tool) => tool.pluginKey === 'user-tool').length;
      expect(userToolCount).toBe(1);
    });

    it('should use checkPluginAuth to verify authentication status', async () => {
      // Add a plugin to availableTools that will be checked
      const mockPlugin = {
        name: 'Tool1',
        pluginKey: 'tool1',
        description: 'Tool 1',
        // No authConfig means checkPluginAuth returns false
      };

      require('~/app/clients/tools').availableTools.push(mockPlugin);

      mockCache.get.mockResolvedValue(null);
      // First call returns null for user tools
      getCachedTools.mockResolvedValueOnce(null);
      mockReq.config = {
        mcpConfig: null,
        paths: { structuredTools: '/mock/path' },
      };

      // Second call (with includeGlobal: true) returns the tool definitions
      getCachedTools.mockResolvedValueOnce({
        tool1: {
          type: 'function',
          function: {
            name: 'tool1',
            description: 'Tool 1',
            parameters: {},
          },
        },
      });

      await getAvailableTools(mockReq, mockRes);

      expect(mockRes.status).toHaveBeenCalledWith(200);
      const responseData = mockRes.json.mock.calls[0][0];
      expect(Array.isArray(responseData)).toBe(true);
      const tool = responseData.find((t) => t.pluginKey === 'tool1');
      expect(tool).toBeDefined();
      // The real checkPluginAuth returns false for plugins without authConfig, so authenticated property is not added
      expect(tool.authenticated).toBeUndefined();
    });

    it('should use getToolkitKey for toolkit validation', async () => {
      const mockToolkit = {
        name: 'Toolkit1',
        pluginKey: 'toolkit1',
        description: 'Toolkit 1',
        toolkit: true,
      };

      require('~/app/clients/tools').availableTools.push(mockToolkit);

      // Mock toolkits to have a mapping
      require('~/app/clients/tools').toolkits.push({
        name: 'Toolkit1',
        pluginKey: 'toolkit1',
        tools: ['toolkit1_function'],
      });

      mockCache.get.mockResolvedValue(null);
      // First call returns null for user tools
      getCachedTools.mockResolvedValueOnce(null);
      mockReq.config = {
        mcpConfig: null,
        paths: { structuredTools: '/mock/path' },
      };

      // Second call (with includeGlobal: true) returns the tool definitions
      getCachedTools.mockResolvedValueOnce({
        toolkit1_function: {
          type: 'function',
          function: {
            name: 'toolkit1_function',
            description: 'Toolkit function',
            parameters: {},
          },
        },
      });

      await getAvailableTools(mockReq, mockRes);

      expect(mockRes.status).toHaveBeenCalledWith(200);
      const responseData = mockRes.json.mock.calls[0][0];
      expect(Array.isArray(responseData)).toBe(true);
      const toolkit = responseData.find((t) => t.pluginKey === 'toolkit1');
      expect(toolkit).toBeDefined();
    });
  });

  describe('plugin.icon behavior', () => {
    const callGetAvailableToolsWithMCPServer = async (serverConfig) => {
      mockCache.get.mockResolvedValue(null);

      const functionTools = {
        [`test-tool${Constants.mcp_delimiter}test-server`]: {
          type: 'function',
          function: {
            name: `test-tool${Constants.mcp_delimiter}test-server`,
            description: 'A test tool',
            parameters: { type: 'object', properties: {} },
          },
        },
      };

      // Mock the MCP manager to return tools and server config
      const mockMCPManager = {
        getAllToolFunctions: jest.fn().mockResolvedValue(functionTools),
        getRawConfig: jest.fn().mockReturnValue(serverConfig),
      };
      require('~/config').getMCPManager.mockReturnValue(mockMCPManager);

      // First call returns empty user tools
      getCachedTools.mockResolvedValueOnce({});

      // Mock getAppConfig to return the mcpConfig
      mockReq.config = {
        mcpConfig: {
          'test-server': serverConfig,
        },
      };

      // Second call (with includeGlobal: true) returns the tool definitions
      getCachedTools.mockResolvedValueOnce(functionTools);

      await getAvailableTools(mockReq, mockRes);
      const responseData = mockRes.json.mock.calls[0][0];
      return responseData.find(
        (tool) => tool.pluginKey === `test-tool${Constants.mcp_delimiter}test-server`,
      );
    };

    it('should set plugin.icon when iconPath is defined', async () => {
      const serverConfig = {
        iconPath: '/path/to/icon.png',
      };
      const testTool = await callGetAvailableToolsWithMCPServer(serverConfig);
      expect(testTool.icon).toBe('/path/to/icon.png');
    });

    it('should set plugin.icon to undefined when iconPath is not defined', async () => {
      const serverConfig = {};
      const testTool = await callGetAvailableToolsWithMCPServer(serverConfig);
      expect(testTool.icon).toBeUndefined();
    });
  });

  describe('helper function integration', () => {
    it('should properly handle MCP tools with custom user variables', async () => {
      const appConfig = {
        mcpConfig: {
          'test-server': {
            customUserVars: {
              API_KEY: { title: 'API Key', description: 'Your API key' },
            },
          },
        },
      };

      // Mock MCP tools returned by getAllToolFunctions
      const mcpToolFunctions = {
        [`tool1${Constants.mcp_delimiter}test-server`]: {
          type: 'function',
          function: {
            name: `tool1${Constants.mcp_delimiter}test-server`,
            description: 'Tool 1',
            parameters: {},
          },
        },
      };

      // Mock the MCP manager to return tools
      const mockMCPManager = {
        getAllToolFunctions: jest.fn().mockResolvedValue(mcpToolFunctions),
        getRawConfig: jest.fn().mockReturnValue({
          customUserVars: {
            API_KEY: { title: 'API Key', description: 'Your API key' },
          },
        }),
      };
      require('~/config').getMCPManager.mockReturnValue(mockMCPManager);

      mockCache.get.mockResolvedValue(null);
      mockReq.config = appConfig;

      // First call returns user tools (empty in this case)
      getCachedTools.mockResolvedValueOnce({});

      // Second call (with includeGlobal: true) returns tool definitions including our MCP tool
      getCachedTools.mockResolvedValueOnce(mcpToolFunctions);

      await getAvailableTools(mockReq, mockRes);

      expect(mockRes.status).toHaveBeenCalledWith(200);
      const responseData = mockRes.json.mock.calls[0][0];
      expect(Array.isArray(responseData)).toBe(true);

      // Find the MCP tool in the response
      const mcpTool = responseData.find(
        (tool) => tool.pluginKey === `tool1${Constants.mcp_delimiter}test-server`,
      );

      // The actual implementation adds authConfig and sets authenticated to false when customUserVars exist
      expect(mcpTool).toBeDefined();
      expect(mcpTool.authConfig).toEqual([
        { authField: 'API_KEY', label: 'API Key', description: 'Your API key' },
      ]);
      expect(mcpTool.authenticated).toBe(false);
    });

    it('should handle error cases gracefully', async () => {
      mockCache.get.mockRejectedValue(new Error('Cache error'));

      await getAvailableTools(mockReq, mockRes);

      expect(mockRes.status).toHaveBeenCalledWith(500);
      expect(mockRes.json).toHaveBeenCalledWith({ message: 'Cache error' });
    });
  });

  describe('edge cases with undefined/null values', () => {
    it('should handle undefined cache gracefully', async () => {
      getLogStores.mockReturnValue(undefined);

      await getAvailableTools(mockReq, mockRes);

      expect(mockRes.status).toHaveBeenCalledWith(500);
    });

    it('should handle null cachedTools and cachedUserTools', async () => {
      mockCache.get.mockResolvedValue(null);
      // First call returns null for user tools
      getCachedTools.mockResolvedValueOnce(null);
      mockReq.config = {
        mcpConfig: null,
        paths: { structuredTools: '/mock/path' },
      };

      // Mock MCP manager to return no tools
      const mockMCPManager = {
        getAllToolFunctions: jest.fn().mockResolvedValue({}),
        getRawConfig: jest.fn().mockReturnValue({}),
      };
      require('~/config').getMCPManager.mockReturnValue(mockMCPManager);

      // Second call (with includeGlobal: true) returns empty object instead of null
      getCachedTools.mockResolvedValueOnce({});

      await getAvailableTools(mockReq, mockRes);

      // Should handle null values gracefully
      expect(mockRes.status).toHaveBeenCalledWith(200);
      expect(mockRes.json).toHaveBeenCalledWith([]);
    });

    it('should handle when getCachedTools returns undefined', async () => {
      mockCache.get.mockResolvedValue(null);
      mockReq.config = {
        mcpConfig: null,
        paths: { structuredTools: '/mock/path' },
      };

      // Mock getCachedTools to return undefined for both calls
      getCachedTools.mockReset();
      getCachedTools.mockResolvedValueOnce(undefined).mockResolvedValueOnce(undefined);

      await getAvailableTools(mockReq, mockRes);

      // Should handle undefined values gracefully
      expect(mockRes.status).toHaveBeenCalledWith(200);
      expect(mockRes.json).toHaveBeenCalledWith([]);
    });

    it('should handle `cachedToolsArray` and `mcpPlugins` both being defined', async () => {
      const cachedTools = [{ name: 'CachedTool', pluginKey: 'cached-tool', description: 'Cached' }];
      // Use MCP delimiter for the user tool so convertMCPToolsToPlugins works
      const userTools = {
        [`user-tool${Constants.mcp_delimiter}server1`]: {
          type: 'function',
          function: {
            name: `user-tool${Constants.mcp_delimiter}server1`,
            description: 'User tool',
            parameters: {},
          },
        },
      };

      mockCache.get.mockResolvedValue(cachedTools);
      getCachedTools.mockResolvedValueOnce(userTools);
      mockReq.config = {
        mcpConfig: {
          server1: {},
        },
        paths: { structuredTools: '/mock/path' },
      };

      // Mock MCP manager to return empty tools initially
      const mockMCPManager = {
        getAllToolFunctions: jest.fn().mockResolvedValue({}),
        getRawConfig: jest.fn().mockReturnValue({}),
      };
      require('~/config').getMCPManager.mockReturnValue(mockMCPManager);

      // The controller expects a second call to getCachedTools
      getCachedTools.mockResolvedValueOnce({
        'cached-tool': { type: 'function', function: { name: 'cached-tool' } },
        [`user-tool${Constants.mcp_delimiter}server1`]:
          userTools[`user-tool${Constants.mcp_delimiter}server1`],
      });

      await getAvailableTools(mockReq, mockRes);

      expect(mockRes.status).toHaveBeenCalledWith(200);
      const responseData = mockRes.json.mock.calls[0][0];
      // Should have both cached and user tools
      expect(responseData.length).toBeGreaterThanOrEqual(2);
    });

    it('should handle empty toolDefinitions object', async () => {
      mockCache.get.mockResolvedValue(null);
      // Reset getCachedTools to ensure clean state
      getCachedTools.mockReset();
      getCachedTools.mockResolvedValue({});
      mockReq.config = {}; // No mcpConfig at all

      // Ensure no plugins are available
      require('~/app/clients/tools').availableTools.length = 0;

      // Reset MCP manager to default state
      const mockMCPManager = {
        getAllToolFunctions: jest.fn().mockResolvedValue({}),
        getRawConfig: jest.fn().mockReturnValue({}),
      };
      require('~/config').getMCPManager.mockReturnValue(mockMCPManager);

      await getAvailableTools(mockReq, mockRes);

      // With empty tool definitions, no tools should be in the final output
      expect(mockRes.json).toHaveBeenCalledWith([]);
    });

    it('should handle MCP tools without customUserVars', async () => {
      const appConfig = {
        mcpConfig: {
          'test-server': {
            // No customUserVars defined
          },
        },
      };

      const mockUserTools = {
        [`tool1${Constants.mcp_delimiter}test-server`]: {
          type: 'function',
          function: {
            name: `tool1${Constants.mcp_delimiter}test-server`,
            description: 'Tool 1',
            parameters: { type: 'object', properties: {} },
          },
        },
      };

      // Mock the MCP manager to return the tools
      const mockMCPManager = {
        getAllToolFunctions: jest.fn().mockResolvedValue(mockUserTools),
        getRawConfig: jest.fn().mockReturnValue({
          // No customUserVars defined
        }),
      };
      require('~/config').getMCPManager.mockReturnValue(mockMCPManager);

      mockCache.get.mockResolvedValue(null);
      mockReq.config = appConfig;
      // First call returns empty user tools
      getCachedTools.mockResolvedValueOnce({});

      // Second call (with includeGlobal: true) returns the tool definitions
      getCachedTools.mockResolvedValueOnce(mockUserTools);

      // Ensure no plugins in availableTools for clean test
      require('~/app/clients/tools').availableTools.length = 0;

      await getAvailableTools(mockReq, mockRes);

      expect(mockRes.status).toHaveBeenCalledWith(200);
      const responseData = mockRes.json.mock.calls[0][0];
      expect(Array.isArray(responseData)).toBe(true);
      expect(responseData.length).toBeGreaterThan(0);

      const mcpTool = responseData.find(
        (tool) => tool.pluginKey === `tool1${Constants.mcp_delimiter}test-server`,
      );

      expect(mcpTool).toBeDefined();
      expect(mcpTool.authenticated).toBe(true);
      // The actual implementation sets authConfig to empty array when no customUserVars
      expect(mcpTool.authConfig).toEqual([]);
    });

    it('should handle undefined filteredTools and includedTools', async () => {
      mockReq.config = {};
      mockCache.get.mockResolvedValue(null);

      // Configure getAppConfig to return config with undefined properties
      // The controller will use default values [] for filteredTools and includedTools
      getAppConfig.mockResolvedValueOnce({});

      await getAvailablePluginsController(mockReq, mockRes);

      expect(mockRes.status).toHaveBeenCalledWith(200);
      expect(mockRes.json).toHaveBeenCalledWith([]);
    });

    it('should handle toolkit with undefined toolDefinitions keys', async () => {
      const mockToolkit = {
        name: 'Toolkit1',
        pluginKey: 'toolkit1',
        description: 'Toolkit 1',
        toolkit: true,
      };

      // No need to mock app.locals anymore as it's not used

      // Add the toolkit to availableTools
      require('~/app/clients/tools').availableTools.push(mockToolkit);

      mockCache.get.mockResolvedValue(null);
      // First call returns empty object
      getCachedTools.mockResolvedValueOnce({});
      mockReq.config = {
        mcpConfig: null,
        paths: { structuredTools: '/mock/path' },
      };

      // Second call (with includeGlobal: true) returns empty object to avoid null reference error
      getCachedTools.mockResolvedValueOnce({});

      await getAvailableTools(mockReq, mockRes);

      // Should handle null toolDefinitions gracefully
      expect(mockRes.status).toHaveBeenCalledWith(200);
    });
  });
});
