const mockGetTokens = jest.fn();
const mockDeleteUserTokens = jest.fn();
const mockGetClientInfoAndMetadata = jest.fn();
const mockRevokeOAuthToken = jest.fn();
const mockGetServerConfig = jest.fn();
const mockGetOAuthServers = jest.fn();
const mockGetAllowedDomains = jest.fn();
const mockGetAllowedAddresses = jest.fn();
const mockDeleteFlow = jest.fn();
const mockGetLogStores = jest.fn();
const mockFindToken = jest.fn();
const mockDeleteTokens = jest.fn();
const mockLoggerInfo = jest.fn();
const mockLoggerWarn = jest.fn();
const mockLoggerError = jest.fn();
const mockGetTenantId = jest.fn();

jest.mock('@librechat/data-schemas', () => ({
  logger: { info: mockLoggerInfo, warn: mockLoggerWarn, error: mockLoggerError },
  getTenantId: (...args) => mockGetTenantId(...args),
  webSearchKeys: [],
}));

jest.mock('@librechat/api', () => {
  return {
    MCPOAuthHandler: {
      revokeOAuthToken: (...args) => mockRevokeOAuthToken(...args),
      generateFlowId: (userId, serverName, tenantId) => {
        const flowId = `${userId}:${serverName}`;
        return tenantId ? `tenant:${encodeURIComponent(tenantId)}:${flowId}` : flowId;
      },
      generateTokenFlowId: (userId, serverName, tenantId) => {
        const flowId = `${userId}:${serverName}`;
        return tenantId ? `tenant:${encodeURIComponent(tenantId)}:${flowId}` : flowId;
      },
    },
    MCPTokenStorage: {
      getTokens: (...args) => mockGetTokens(...args),
      getClientInfoAndMetadata: (...args) => mockGetClientInfoAndMetadata(...args),
      deleteUserTokens: (...args) => mockDeleteUserTokens(...args),
    },
    normalizeHttpError: jest.fn(),
    extractWebSearchEnvVars: jest.fn(),
    needsRefresh: jest.fn(),
    getNewS3URL: jest.fn(),
  };
});

jest.mock('librechat-data-provider', () => ({
  Tools: {},
  CacheKeys: { FLOWS: 'flows' },
  Constants: { mcp_delimiter: '::', mcp_prefix: 'mcp_' },
  FileSources: {},
  ResourceType: {},
}));

jest.mock('~/config', () => ({
  getMCPManager: jest.fn(),
  getFlowStateManager: jest.fn(() => ({
    deleteFlow: (...args) => mockDeleteFlow(...args),
  })),
  getMCPServersRegistry: jest.fn(() => ({
    getServerConfig: (...args) => mockGetServerConfig(...args),
    getOAuthServers: (...args) => mockGetOAuthServers(...args),
    getAllowedDomains: (...args) => mockGetAllowedDomains(...args),
    getAllowedAddresses: (...args) => mockGetAllowedAddresses(...args),
  })),
}));

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

jest.mock('~/server/services/PluginService', () => ({
  updateUserPluginAuth: jest.fn(),
  deleteUserPluginAuth: jest.fn(),
}));

jest.mock('~/server/services/twoFactorService', () => ({
  verifyOTPOrBackupCode: jest.fn(),
}));

jest.mock('~/server/services/AuthService', () => ({
  verifyEmail: jest.fn(),
  resendVerificationEmail: jest.fn(),
}));

jest.mock('~/server/services/Config/getCachedTools', () => ({
  invalidateCachedTools: jest.fn(),
}));

jest.mock('~/server/services/Files/process', () => ({
  processDeleteRequest: jest.fn().mockResolvedValue({ deletedFileIds: [], failedFileIds: [] }),
}));

jest.mock('~/server/services/Config', () => ({
  getAppConfig: jest.fn(),
}));

jest.mock('~/models', () => ({
  findToken: (...args) => mockFindToken(...args),
  deleteTokens: (...args) => mockDeleteTokens(...args),
  updateUser: jest.fn(),
  deleteAllUserSessions: jest.fn(),
  deleteAllSharedLinks: jest.fn(),
  updateUserPlugins: jest.fn(),
  deleteUserById: jest.fn(),
  deleteMessages: jest.fn(),
  deletePresets: jest.fn(),
  deleteUserKey: jest.fn(),
  getUserById: jest.fn(),
  deleteConvos: jest.fn(),
  deleteFiles: jest.fn(),
  getFiles: jest.fn(),
  deleteToolCalls: jest.fn(),
  deleteUserAgents: jest.fn(),
  deleteUserPrompts: jest.fn(),
  deleteTransactions: jest.fn(),
  deleteBalances: jest.fn(),
  deleteAllAgentApiKeys: jest.fn(),
  deleteAssistants: jest.fn(),
  deleteConversationTags: jest.fn(),
  deleteAllUserMemories: jest.fn(),
  deleteActions: jest.fn(),
  removeUserFromAllGroups: jest.fn(),
  deleteAclEntries: jest.fn(),
  getSoleOwnedResourceIds: jest.fn().mockResolvedValue([]),
}));

const { maybeUninstallOAuthMCP } = require('~/server/controllers/UserController');

const userId = 'user-123';
const pluginKey = 'mcp_acme';
const serverName = 'acme';

const serverConfig = {
  url: 'https://acme.example.com',
  oauth: {
    revocation_endpoint: 'https://acme.example.com/revoke',
    revocation_endpoint_auth_methods_supported: ['client_secret_basic'],
  },
  oauth_headers: { 'X-Tenant': 'acme' },
};

const appConfig = {
  mcpServers: { acme: serverConfig },
};

const clientInfo = { client_id: 'cid', client_secret: 'csec' };
const clientMetadata = {};

function setupOAuthServerFound() {
  mockGetServerConfig.mockResolvedValue(serverConfig);
  mockGetOAuthServers.mockResolvedValue(new Set([serverName]));
  mockGetAllowedDomains.mockReturnValue(['https://acme.example.com']);
  mockGetAllowedAddresses.mockReturnValue(null);
  mockGetClientInfoAndMetadata.mockResolvedValue({ clientInfo, clientMetadata });
}

describe('maybeUninstallOAuthMCP', () => {
  beforeEach(() => {
    jest.clearAllMocks();
    mockGetTenantId.mockReturnValue(undefined);
  });

  test('is a no-op when pluginKey is not an MCP key', async () => {
    await maybeUninstallOAuthMCP(userId, 'plugin_google_calendar', appConfig);

    expect(mockGetServerConfig).not.toHaveBeenCalled();
    expect(mockGetTokens).not.toHaveBeenCalled();
    expect(mockDeleteUserTokens).not.toHaveBeenCalled();
    expect(mockDeleteFlow).not.toHaveBeenCalled();
  });

  test('clears stored state when the MCP server is not an OAuth server', async () => {
    mockGetServerConfig.mockResolvedValue(serverConfig);
    mockGetOAuthServers.mockResolvedValue(new Set(['other']));

    await maybeUninstallOAuthMCP(userId, pluginKey, appConfig);

    expect(mockGetClientInfoAndMetadata).not.toHaveBeenCalled();
    expect(mockGetTokens).not.toHaveBeenCalled();
    expect(mockDeleteUserTokens).toHaveBeenCalledTimes(1);
    expect(mockDeleteUserTokens.mock.calls[0][0]).toMatchObject({ userId, serverName });
    expect(mockDeleteFlow).toHaveBeenCalledTimes(2);
  });

  test('clears stored state when client info is missing', async () => {
    setupOAuthServerFound();
    mockGetClientInfoAndMetadata.mockResolvedValue(null);

    await maybeUninstallOAuthMCP(userId, pluginKey, appConfig);

    expect(mockGetTokens).not.toHaveBeenCalled();
    expect(mockDeleteUserTokens).toHaveBeenCalledTimes(1);
    expect(mockDeleteUserTokens.mock.calls[0][0]).toMatchObject({ userId, serverName });
    expect(mockDeleteFlow).toHaveBeenCalledTimes(2);
  });

  test('clears stored state when client info cannot be loaded', async () => {
    setupOAuthServerFound();
    mockGetClientInfoAndMetadata.mockRejectedValue(new Error('bad client data'));
    mockDeleteUserTokens.mockResolvedValue(undefined);
    mockDeleteFlow.mockResolvedValue(undefined);

    await maybeUninstallOAuthMCP(userId, pluginKey, appConfig);

    expect(mockGetTokens).not.toHaveBeenCalled();
    expect(mockDeleteUserTokens).toHaveBeenCalledTimes(1);
    expect(mockDeleteUserTokens.mock.calls[0][0]).toMatchObject({ userId, serverName });
    expect(mockDeleteFlow).toHaveBeenCalledTimes(2);
    expect(mockLoggerWarn).toHaveBeenCalledWith(
      `[maybeUninstallOAuthMCP] Unable to load OAuth client metadata for ${serverName}; clearing local MCP OAuth state only.`,
      expect.any(Error),
    );
  });

  test('clears tenant-scoped and legacy flow state when tenant context exists', async () => {
    setupOAuthServerFound();
    mockGetTenantId.mockReturnValue('tenant-a');
    mockGetClientInfoAndMetadata.mockResolvedValue(null);

    await maybeUninstallOAuthMCP(userId, pluginKey, appConfig);

    expect(mockDeleteFlow).toHaveBeenCalledTimes(4);
    expect(mockDeleteFlow).toHaveBeenCalledWith('tenant:tenant-a:user-123:acme', 'mcp_get_tokens');
    expect(mockDeleteFlow).toHaveBeenCalledWith('tenant:tenant-a:user-123:acme', 'mcp_oauth');
    expect(mockDeleteFlow).toHaveBeenCalledWith('user-123:acme', 'mcp_get_tokens');
    expect(mockDeleteFlow).toHaveBeenCalledWith('user-123:acme', 'mcp_oauth');
  });

  test('revokes both tokens and runs cleanup on happy path', async () => {
    setupOAuthServerFound();
    mockGetTokens.mockResolvedValue({
      access_token: 'access-abc',
      refresh_token: 'refresh-xyz',
    });
    mockRevokeOAuthToken.mockResolvedValue(undefined);
    mockDeleteUserTokens.mockResolvedValue(undefined);
    mockDeleteFlow.mockResolvedValue(undefined);

    await maybeUninstallOAuthMCP(userId, pluginKey, appConfig);

    expect(mockRevokeOAuthToken).toHaveBeenCalledTimes(2);
    expect(mockRevokeOAuthToken.mock.calls[0][1]).toBe('access-abc');
    expect(mockRevokeOAuthToken.mock.calls[0][2]).toBe('access');
    expect(mockRevokeOAuthToken.mock.calls[1][1]).toBe('refresh-xyz');
    expect(mockRevokeOAuthToken.mock.calls[1][2]).toBe('refresh');

    expect(mockDeleteUserTokens).toHaveBeenCalledTimes(1);
    expect(mockDeleteUserTokens.mock.calls[0][0]).toMatchObject({ userId, serverName });

    expect(mockDeleteFlow).toHaveBeenCalledTimes(2);
    expect(mockDeleteFlow.mock.calls[0][1]).toBe('mcp_get_tokens');
    expect(mockDeleteFlow.mock.calls[1][1]).toBe('mcp_oauth');
  });

  test('skips revocation but still runs cleanup when token retrieval fails', async () => {
    setupOAuthServerFound();
    mockGetTokens.mockRejectedValue(new Error('missing'));
    mockDeleteUserTokens.mockResolvedValue(undefined);
    mockDeleteFlow.mockResolvedValue(undefined);

    await expect(maybeUninstallOAuthMCP(userId, pluginKey, appConfig)).resolves.toBeUndefined();

    expect(mockRevokeOAuthToken).not.toHaveBeenCalled();
    expect(mockDeleteUserTokens).toHaveBeenCalledTimes(1);
    expect(mockDeleteFlow).toHaveBeenCalledTimes(2);
    expect(mockLoggerWarn).toHaveBeenCalledWith(
      `[maybeUninstallOAuthMCP] Unable to load OAuth tokens for ${serverName}; clearing local token state.`,
      expect.any(Error),
    );
  });

  test('skips revocation, logs warn, and still runs cleanup on unexpected token-retrieval error', async () => {
    setupOAuthServerFound();
    mockGetTokens.mockRejectedValue(new Error('boom: unreachable'));
    mockDeleteUserTokens.mockResolvedValue(undefined);
    mockDeleteFlow.mockResolvedValue(undefined);

    await expect(maybeUninstallOAuthMCP(userId, pluginKey, appConfig)).resolves.toBeUndefined();

    expect(mockRevokeOAuthToken).not.toHaveBeenCalled();
    expect(mockDeleteUserTokens).toHaveBeenCalledTimes(1);
    expect(mockDeleteFlow).toHaveBeenCalledTimes(2);
    expect(mockLoggerWarn).toHaveBeenCalledWith(
      `[maybeUninstallOAuthMCP] Unable to load OAuth tokens for ${serverName}; clearing local token state.`,
      expect.any(Error),
    );
  });

  test('continues cleanup when only one token type is present', async () => {
    setupOAuthServerFound();
    mockGetTokens.mockResolvedValue({ access_token: 'only-access' });
    mockRevokeOAuthToken.mockResolvedValue(undefined);
    mockDeleteUserTokens.mockResolvedValue(undefined);
    mockDeleteFlow.mockResolvedValue(undefined);

    await maybeUninstallOAuthMCP(userId, pluginKey, appConfig);

    expect(mockRevokeOAuthToken).toHaveBeenCalledTimes(1);
    expect(mockRevokeOAuthToken.mock.calls[0][2]).toBe('access');
    expect(mockDeleteUserTokens).toHaveBeenCalledTimes(1);
    expect(mockDeleteFlow).toHaveBeenCalledTimes(2);
  });

  test('still runs cleanup even when both revocation calls fail', async () => {
    setupOAuthServerFound();
    mockGetTokens.mockResolvedValue({
      access_token: 'a',
      refresh_token: 'r',
    });
    mockRevokeOAuthToken.mockRejectedValue(new Error('network down'));
    mockDeleteUserTokens.mockResolvedValue(undefined);
    mockDeleteFlow.mockResolvedValue(undefined);

    await expect(maybeUninstallOAuthMCP(userId, pluginKey, appConfig)).resolves.toBeUndefined();

    expect(mockRevokeOAuthToken).toHaveBeenCalledTimes(2);
    expect(mockDeleteUserTokens).toHaveBeenCalledTimes(1);
    expect(mockDeleteFlow).toHaveBeenCalledTimes(2);
    expect(mockLoggerError).toHaveBeenCalled();
  });
});
