import json
import copy
import base64
import traceback
import logging
from datetime import datetime
from uuid import UUID
from jwcrypto import jwk, jwe, jwt
from jwcrypto.common import json_decode
from operator import itemgetter
from bson import ObjectId
from pymongo import MongoClient
from scripts.utils.mongo_exceptions import MongoException, MongoConnectionException, MongoQueryException, MongoEncryptionException,\
    MongoRecordInsertionException, MongoFindException, MongoDeleteException, MongoUpdateException,\
    MongoUnknownDatatypeException
from scripts.utils.mongo_constants import private_jwe_key, enc_alg_header, product_encrypted, encrypt_collection_dict,\
    key_encrypt_keys, key_exclude_encryption
from scripts.utils.mongo_exception_codes import MONGO001, MONGO002, MONGO003, MONGO004, MONGO005, MONGO006, MONGO007
from pymongo.errors import ConnectionFailure
from pymongo.errors import DuplicateKeyError as MongoDuplicateKeyError

# TODO: Use the respective product's logger class
logger = logging.getLogger("")


exclude_encryption_datatypes = (datetime, UUID,)


class MongoDataEncryption(object):
    def __init__(self):
        pass

    @staticmethod
    def create_encrypted_string(payload):
        """
        Method to encrypt the payload given as input.
        :param payload: The string to be encrypted.
        :return: Encrypted payload
        """
        cookie_string = ""
        try:
            existing_private_key = jwk.JWK.from_pem(private_jwe_key['k'].encode())
            existing_public_key = jwk.JWK()
            existing_public_key.import_key(**json_decode(existing_private_key.export_public()))
            jwe_token = jwe.JWE(plaintext=json.dumps(payload).encode(), protected=enc_alg_header,
                                recipient=existing_public_key)
            cookie_string = jwe_token.serialize(compact=True)
        except Exception as e:
            logger.exception("Exception in the create_encrypted_string definition: " + str(e))
            traceback.print_exc()
        return cookie_string

    @staticmethod
    def create_decrypted_string(encrypted_string):
        """
        Method to decrypt the payload given as input
        :param encrypted_string: The encrypted string to be decoded.
        :return: The original value in string format
        """
        decrypted_payload = ""
        try:
            existing_private_key = jwk.JWK.from_pem(private_jwe_key['k'].encode())
            cookie_string = jwt.JWT(key=existing_private_key, jwt=encrypted_string)
            decrypted_payload = cookie_string.claims
        except Exception as e:
            logger.exception("Exception in the create_decrypted_string definition: " + str(e))
            traceback.print_exc()
        return decrypted_payload

    def encrypt_data(self, json_data, collection_name):
        """
        Encrypt the data in mongo based on the collection and key to be encrypted.
        :param json_data: The data to be encrypted
        :param collection_name: The collection where the document is stored
        :return: Encrypted document based on product defined configuration.
        """
        # TODO: Automatically add an unsupported data type to the salt.
        try:
            if collection_name in encrypt_collection_dict.keys():
                if type(json_data) is list:
                    encrypted_data = list()
                    for data in encrypted_data:
                        dict_data = self.encrypt_dict_data(doc=data, collection_name=collection_name)
                        encrypted_data.append(dict_data)
                elif type(json_data) is dict:
                    encrypted_data = self.encrypt_dict_data(doc=json_data, collection_name=collection_name)
                else:
                    raise MongoUnknownDatatypeException("Unsupported datatype '{}' is being inserted to mongodb.".
                                                        format(type(json_data)))
            else:
                logger.debug("Given data is not a part of the Mongo encryption setup. Skipping encryption")
                encrypted_data = json_data
                encrypted_data[product_encrypted] = False
            return encrypted_data
        except MongoException as e:
            raise MongoException(str(e))
        except Exception as e:
            raise MongoException("Server faced a problem when encrypting the data --> {}".format(str(e)))

    def encrypt_dict_data(self, doc, collection_name):
        """
        This method crawls the document and encrypts the keys that are marked for encryption.
        Skips encrypting the keys with values of the datatypes defines in the tuple 'exclude_encryption_datatypes'
        Adds two new keys to the document 'product_encrypted' and 'encryption_salt'
        Key product_encrypted - Is a boolean value which flags a document as encrypted by this utility.
        Key encryption_salt - List of all the values that were excluded from encryption due to datatype constraints.
        :param doc: The document considered for encryption
        :param collection_name: The collection where the document resided.
                                This is needed for the utility to read the encryption configuration
        :return: The input document with the relevant keys encrypted.
        """
        try:
            is_mlens_encrypted = False
            encrypted_data = dict()
            encrypted_data["encryption_salt"] = dict()
            if '*' in encrypt_collection_dict[collection_name][key_encrypt_keys]:
                # Forming encryption salt
                for index, exclude_encryption_datatype in enumerate(exclude_encryption_datatypes):
                    if exclude_encryption_datatype not in [None, '']:
                        encrypted_data["encryption_salt"]["dt_{}".format(index)] = \
                            self.search_datatype(doc, exclude_encryption_datatype)
                        sorted_path = sorted(encrypted_data["encryption_salt"]["dt_{}".format(index)],
                                             key=itemgetter('p'), reverse=True)
                        for path_index, _path in enumerate(sorted_path):
                            to_pop = self.remove_value_of_datatype_command(_path, "dict_data")
                            exec(to_pop)
                for dt in encrypted_data["encryption_salt"]:
                    for path_index, _path in enumerate(encrypted_data["encryption_salt"][dt]):
                        encrypted_data["encryption_salt"][dt][path_index]['p'] = base64.b64encode(_path['p'].encode())

                # Encrypting the data
                for key in doc.keys():
                    if key not in encrypt_collection_dict[collection_name][key_exclude_encryption]:
                        encrypted_data[key] = {'d': self.create_encrypted_string(payload=self.convert(doc[key])),
                                               't': base64.b64encode(type(doc[key]).__name__.encode())}
                        is_mlens_encrypted = True
                    else:
                        encrypted_data[key] = doc[key]
            else:
                for key in doc.keys():
                    if key in encrypt_collection_dict[collection_name][key_encrypt_keys]:
                        # Forming encryption salt
                        for index, exclude_encryption_datatype in enumerate(exclude_encryption_datatypes):
                            if exclude_encryption_datatype not in [None, '']:
                                temp_dict_data = dict()
                                temp_dict_data[key] = copy.deepcopy(doc[key])
                                encrypted_data["encryption_salt"]["dt_{}".format(index)] = \
                                    self.search_datatype(temp_dict_data, exclude_encryption_datatype)
                                sorted_path = sorted(encrypted_data["encryption_salt"]["dt_{}".format(index)],
                                                     key=itemgetter('p'), reverse=True)
                                for path_index, _path in enumerate(sorted_path):
                                    to_pop = self.remove_value_of_datatype_command(_path, "dict_data")
                                    exec(to_pop)
                        for dt in encrypted_data["encryption_salt"]:
                            for path_index, _path in enumerate(encrypted_data["encryption_salt"][dt]):
                                encrypted_data["encryption_salt"][dt][path_index]['p'] = base64.b64encode(
                                    _path['p'].encode())
                        # Encrypting the data
                        encrypted_data[key] = {'d': self.create_encrypted_string(payload=self.convert(doc[key])),
                                               't': base64.b64encode(type(doc[key]).__name__.encode())}
                        is_mlens_encrypted = True
                    else:
                        encrypted_data[key] = doc[key]
            encrypted_data[product_encrypted] = is_mlens_encrypted
            if not encrypted_data[product_encrypted]:
                del encrypted_data["encryption_salt"]
            return encrypted_data
        except MongoException as e:
            raise MongoException(str(e))
        except Exception as e:
            raise MongoException("Server faced a problem when encrypting the data --> {}".format(str(e)))

    def decrypt_data(self, encrypted_doc, collection_name):
        """
        This method decrypts all the data that is encrypted.
        Keys that were excluded during encryption and have been added to the encryption_salt
        will be added back to their original positions.
        :param encrypted_doc: The document that needs to be decrypted
        :param collection_name: The collection to which the document belongs to.
        :return: The decrypted data with the original data types intact
        """
        try:
            if collection_name in encrypt_collection_dict.keys():
                if '*' in encrypt_collection_dict[collection_name][key_encrypt_keys]:
                    decrypted_data = self.decrypt_keys(encrypted_doc=encrypted_doc, collection_name=collection_name,
                                                       key_based=False)
                else:
                    decrypted_data = self.decrypt_keys(encrypted_doc=encrypted_doc, collection_name=collection_name,
                                                       key_based=True)
            else:
                decrypted_data = encrypted_doc
            if product_encrypted in encrypted_doc and encrypted_doc[product_encrypted]:
                if "encryption_salt" in encrypted_doc:
                    for dt in encrypted_doc["encryption_salt"]:
                        for val_index, val in enumerate(encrypted_doc["encryption_salt"][dt]):
                            encrypted_doc["encryption_salt"][dt][val_index]['p'] = \
                                base64.b64decode(encrypted_doc["encryption_salt"][dt][val_index]['p'].decode()).decode()
                    for dt in encrypted_doc["encryption_salt"]:
                        for val_index, val in enumerate(sorted(encrypted_doc["encryption_salt"][dt],
                                                               key=itemgetter('p'))):
                            to_add = self.add_value_datatype_command(
                                add_value=encrypted_doc["encryption_salt"][dt][val_index],
                                var_name="decrypted_data",
                                value="dict_data[\"encryption_salt\"][dt][val_index]['v']")
                            exec(to_add)

                else:
                    raise MongoEncryptionException("Encrypted data does not have encryption salt!"
                                                   " Unable to decrypt the data!")
            if product_encrypted in decrypted_data:
                del decrypted_data[product_encrypted]
            if "encryption_salt" in decrypted_data:
                del decrypted_data["encryption_salt"]
            return decrypted_data
        except MongoException as e:
            raise MongoException(str(e))
        except Exception as e:
            raise MongoException("Server faced a problem when decrypting the data --> {}".format(str(e)))

    def decrypt_keys(self, encrypted_doc, collection_name, key_based=False):
        """
        This method loops through the document and decrypts all the keys.
        :param encrypted_doc: The document that needs to be decrypted
        :param collection_name: The collection to which the document belongs to.
        :param key_based: If decryption should be done based on key or on all keys (*)
        :return:
        """
        try:
            decrypted_data = dict()
            if key_based:
                condition_dict = encrypt_collection_dict[collection_name][key_encrypt_keys]
            else:
                condition_dict = encrypt_collection_dict[collection_name][key_exclude_encryption]
            for key in encrypted_doc.keys():
                if key in condition_dict and not isinstance(encrypted_doc[key], exclude_encryption_datatypes):
                    if type(encrypted_doc[key]) is dict:
                        if 'd' in encrypted_doc[key].keys() and 't' in encrypted_doc[key].keys():
                            decrypted_data[key] = self.decrypt_convert_proper_data_type(
                                data=self.create_decrypted_string(encrypted_string=encrypted_doc[key]['d']),
                                data_type=base64.b64decode(encrypted_doc[key]['t'].decode()).decode()
                            )
                        else:
                            decrypted_data[key] = encrypted_doc[key]
                    else:
                        decrypted_data[key] = encrypted_doc[key]
                else:
                    decrypted_data[key] = encrypted_doc[key]
            return decrypted_data
        except Exception as e:
            raise MongoException("Server faced a problem when decrypting the keys --> {}".format(str(e)))

    @staticmethod
    def decrypt_convert_proper_data_type(data, data_type):
        """
        Convert the de-serialized JSON object to the original data-type
        :param data: The de-serialized data
        :param data_type: The original data type to which the de-serialized data should be converted to
        :return: The de-serialized data with it's original data type.
        """
        if data_type == "int":
            return int(data)
        elif data_type == "list":
            return json.loads(data)
        elif data_type == "dict":
            return json.loads(data)
        elif data_type == "bool":
            if data == 'true':
                return True
            elif data == 'false':
                return False
            else:
                raise MongoException("Received unknown bool value (only true/false accepted)")
        else:
            return data.lstrip('"').rstrip('"')

    def convert(self, data):
        """
        Convert all byte-like objects into the proper data types.
        This supports conversion of nested dict, list and tuples.
        :param data:
        :return:
        """
        if isinstance(data, bytes):
            return data.decode('ascii')
        if isinstance(data, dict):
            return dict(map(self.convert, data.items()))
        if isinstance(data, tuple):
            return map(self.convert, data)
        if isinstance(data, list):
            return list(map(self.convert, data))
        return data

    def search_datatype(self, _input, search_type, prev_datapoint_path=''):
        """
        Search for an excluded data type in a nested dictionary or list and record it's path in the document.
        This does not support the exclusion of data of types dict and list.
        :param _input: The input data
        :param search_type: The data type to be searched for to exclude.
        :param prev_datapoint_path: The path of a value in a nested dict or nested list.
        :return: List of dictionaries, with each dictionary containing the true value and it's path.
        """
        try:
            output = []
            current_datapoint = _input
            current_datapoint_path = prev_datapoint_path
            if search_type is dict:
                raise Exception("Searching for datatype dict is not supported!")
            elif search_type is list:
                raise Exception("Searching for datatype list is not supported!")
            else:
                if isinstance(current_datapoint, dict):
                    for dkey in current_datapoint:
                        temp_datapoint_path = current_datapoint_path
                        temp_datapoint_path += "dict-{}.".format(dkey)
                        for index in self.search_datatype(current_datapoint[dkey], search_type, temp_datapoint_path):
                            output.append(index)
                elif isinstance(current_datapoint, list):
                    for index in range(0, len(current_datapoint)):
                        temp_datapoint_path = current_datapoint_path
                        temp_datapoint_path += "list-{}.".format(index)
                        for index_1 in self.search_datatype(current_datapoint[index], search_type, temp_datapoint_path):
                            output.append(index_1)
                elif isinstance(current_datapoint, search_type):
                    output.append(dict(p=current_datapoint_path, v=current_datapoint))
                output = filter(None, output)
                return list(output)
        except Exception as e:
            raise Exception("Server faced a problem when searching for instances of datatype '{}' --> ".
                            format(search_type, str(e)))

    @staticmethod
    def remove_value_of_datatype_command(remove_value, var_name):
        """
        This method produces the command for the value to be removed from a nested dict or list,
        when given the path of that value in the source variable.
        :param remove_value: The value (it's path) to be removed.
        :param var_name: The variable on which the exec function should run on to remove the non-serializable value.
        :return: The final command that will run in the exec function to remove the value from a nested dict or list.
        """
        temp_path = ''
        individual_path_list = remove_value["p"].split('.')
        individual_path_list.remove('')
        if individual_path_list[len(individual_path_list) - 1].split('-')[0] == "dict":
            orig_path = 'del {var_name}{path}'
        elif individual_path_list[len(individual_path_list) - 1].split('-')[0] == "list":
            pop_index = ".pop({})".format(individual_path_list[len(individual_path_list) - 1].split('-')[1])
            orig_path = '{var_name}{path}' + pop_index
            individual_path_list.pop(len(individual_path_list) - 1)
        else:
            return
        for path_index, path in enumerate(individual_path_list):
            if path.split('-')[0] == "dict":
                temp_path += "[\"{}\"]".format(path.split('-')[1])
            elif path.split('-')[0] == "list":
                temp_path += "[{}]".format(path.split('-')[1])
        orig_path = orig_path.format(path=temp_path, var_name=var_name)
        return orig_path

    @staticmethod
    def add_value_datatype_command(add_value, var_name, value):
        """
        This method produces the command for the value to be added back to a nested dict or list,
        when given the path of that value in the source variable.
        :param add_value: The value (it's path) to be added
        :param var_name: The source variable name on which the exec function should run on.
        :param value: The original non-serialized value.
        :return: The command to be executed on the source variable.
        """
        path_string = ''
        temp_path_string = ''
        individual_path_list = add_value["p"].split('.')
        individual_path_list.remove('')
        for path_index, path in enumerate(individual_path_list):
            if path.split('-')[0] == "dict":
                temp_path_string = "[\"{}\"]".format(path.split('-')[1])
            elif path.split('-')[0] == "list":
                temp_path_string = "[{}]".format(path.split('-')[1])
            else:
                raise Exception("Unsupported datatype given for add value")
            path_string += temp_path_string
        if individual_path_list[len(individual_path_list) - 1].split('-')[0] == "dict":
            command = "{var_name}{path} = {value}".format(var_name=var_name, path=path_string, value=value)
        elif individual_path_list[len(individual_path_list) - 1].split('-')[0] == "list":
            command = "{var_name}{path}].append({value})".format(var_name=var_name,
                                                                 path=path_string.rstrip(temp_path_string),
                                                                 value=value)
        else:
            raise Exception("Unsupported datatype given for add value")
        return command


class MongoUtility(MongoDataEncryption):
    def __init__(self, _mongo_host, _mongo_port):
        super().__init__()
        try:
            self.__mongo_OBJ__ = MongoClient(host=_mongo_host, port=int(_mongo_port))
            try:
                self.__mongo_OBJ__.admin.command('ismaster')
            except ConnectionFailure:
                raise MongoConnectionException("MLens was unable to create a connection with the metastore")
            logger.debug("Mongo connection established")

        except Exception as e:
            logger.error("Error in establishing connection: " + str(e))
            raise MongoConnectionException(MONGO001)

    def fetch_last_records_with_limit(self, json_data, database_name, collection_name, condition_json, limit=1):
        """
        Fetches the latest n records given a condition.
        :param json_data: The condition by which the records need to be fetched from MongoDB
        :param database_name: The database from which the documents need to be fetched.
        :param collection_name: The collection from which the documents need to be fetched.
        :param condition_json: The condition to filter the fetched records.
        :param limit: The number of records to be found.
        :return:
        """
        try:
            db = self.__mongo_OBJ__[database_name]
            mongo_response = db[collection_name].find(json_data).sort(condition_json).limit(limit)
            logger.debug("Fetched results from mongo")
            mongo_response = self.fetch_records_from_object(mongo_response_obj=mongo_response,
                                                            collection_name=collection_name)
            return mongo_response
        except Exception as e:
            traceback.print_exc()
            logger.error("Error in finding document: " + str(e))
            raise MongoFindException(MONGO003)

    def insert_one(self, json_data, database_name, collection_name):
        """
        To insert single document in collection
        :param json_data: The document data to be inserted.
        :param database_name: The database to which the collection/ document belongs to.
        :param collection_name: The collection to which the document belongs to.
        :return: id
        """
        try:
            json_data = self.encrypt_data(json_data=json_data, collection_name=collection_name)
            mongo_response = self.__mongo_OBJ__[database_name][collection_name].insert_one(json_data)
            logger.debug("Inserted document in mongo")
            return mongo_response.inserted_id
        except MongoException as e:
            raise MongoException(e)
        except MongoDuplicateKeyError:
            raise MongoDuplicateKeyError("Found an existing record with the same ID in MongoDB")
        except Exception as e:
            traceback.print_exc()
            logger.error("Error in inserting document: " + str(e))
            raise MongoRecordInsertionException(MONGO002)

    def insert_many(self, json_data, collection_name, database_name):
        """
        To insert multiple documents in collection
        :param json_data: The document data to be inserted.
        :param collection_name: The collection to which the documents belongs to.
        :param database_name: The database to which the collection/ documents belongs to.
        :return: response
        """
        try:
            json_data = self.encrypt_data(json_data=json_data, collection_name=collection_name)
            mongo_response = self.__mongo_OBJ__[database_name][collection_name].insert_many(json_data)
            json_mongo_response_object = json.loads(json.dumps(mongo_response))
            logger.debug("Inserted documents in mongo")
            return json_mongo_response_object
        except Exception as e:
            traceback.print_exc()
            logger.error("Error in inserting document: " + str(e))
            raise MongoRecordInsertionException(MONGO002)

    def find_json(self, json_data, database_name, collection_name):
        """
        To find single document in collection
        :param json_data: The condition on which the documents are to be found.
        :param database_name: The database to which the collection/ documents belongs to.
        :param collection_name: The collection to which the documents belongs to.
        :return: response object
        """
        try:
            db = self.__mongo_OBJ__[database_name]
            mongo_response = db[collection_name].find(json_data)
            mongo_response = self.fetch_records_from_object(mongo_response_obj=mongo_response,
                                                            collection_name=collection_name)
            logger.debug("Fetched results from mongo")
            return mongo_response
        except Exception as e:
            traceback.print_exc()
            logger.error("Error in finding document: " + str(e))
            raise MongoFindException(MONGO003)

    def find_all(self, database_name, collection_name):
        """
        To find all the documents
        :param database_name: The database to which the collection/ documents belongs to.
        :param collection_name: The collection to which the documents belongs to.
        :return: response object
        """
        try:
            db = self.__mongo_OBJ__[database_name]
            mongo_response = db[collection_name].find()
            mongo_response = self.fetch_records_from_object(mongo_response_obj=mongo_response,
                                                            collection_name=collection_name)
            logger.debug("Fetched results from mongo")
            return mongo_response
        except Exception as e:
            traceback.print_exc()
            logger.error("Error in finding document: " + str(e))
            raise MongoFindException(MONGO003)

    def find_json_unencrypted(self, json_data, database_name, collection_name):
        """
        To find single document in collection
        :param json_data: Find a document without running the decryption module on it.
        :param database_name: The database to which the collection/ documents belongs to.
        :param collection_name: The collection to which the documents belongs to.
        :return: response object
        """
        try:
            db = self.__mongo_OBJ__[database_name]
            mongo_response = db[collection_name].find(json_data)
            logger.debug("Fetched results from mongo")
            return mongo_response
        except Exception as e:
            traceback.print_exc()
            logger.error("Error in finding document: " + str(e))
            raise MongoFindException(MONGO003)

    def find_all_unencrypted(self, database_name, collection_name):
        """
        To find all the documents
        :param database_name: The database to which the collection/ documents belongs to.
        :param collection_name: The collection to which the documents belongs to.
        :return: response object
        """
        try:
            db = self.__mongo_OBJ__[database_name]
            mongo_response = db[collection_name].find()
            logger.debug("Fetched results from mongo")
            return mongo_response
        except Exception as e:
            traceback.print_exc()
            logger.error("Error in finding document: " + str(e))
            raise MongoFindException(MONGO003)

    def find_with_limit(self, json_data, database_name, collection_name, limit=1):
        """
        Find n documents with a certain key/ value pair.
        :param json_data: The key/ value pair based on which the documents should be fetched from Mongo.
        :param database_name: The database to which the collection/ documents belongs to.
        :param collection_name: The collection to which the documents belongs to.
        :param limit: The number of records that the output should be restricted to.
        :return: List of all the documents.
        """
        try:
            database_connection = self.__mongo_OBJ__[database_name]
            mongo_response = database_connection[collection_name].find(json_data)
            mongo_response1 = mongo_response.limit(limit)
            logger.debug("Fetched results from mongo")
            mongo_response1 = self.fetch_records_from_object(mongo_response_obj=mongo_response1,
                                                             collection_name=collection_name)
            return mongo_response1
        except Exception as e:
            traceback.print_exc()
            logger.error("Error in finding document: " + str(e))
            raise MongoFindException(MONGO003)

    def find_with_keyword(self, keyword_dict, database_name, collection_name):
        """
        To find and return all documents with selected keywords
        :param keyword_dict: The keyword based on which the documents should be fetched.
        :param database_name: The database to which the collection/ documents belongs to.
        :param collection_name: The collection to which the documents belongs to.
        :return: List of all the documents.
        """
        try:
            database_connection = self.__mongo_OBJ__[database_name]
            mongo_response = database_connection[collection_name].find({}, keyword_dict)
            mongo_response = self.fetch_records_from_object(mongo_response_obj=mongo_response,
                                                            collection_name=collection_name)
            logger.debug("Fetched results from mongo")
            return mongo_response
        except Exception as e:
            traceback.print_exc()
            logger.error("Error in finding document: " + str(e))
            raise MongoFindException(MONGO003)

    def find_json_with_keyword(self, keyword_dict, json_data, database_name, collection_name):
        """
        Find and return all documents with selected keywords and a key/ value pair.
        :param keyword_dict: The keyword based on which the documents should be fetched.
        :param json_data: The key/ value pair based on which the documents should be fetched.
        :param database_name: The database to which the collection/ documents belongs to.
        :param collection_name: The collection to which the documents belongs to.
        :return: List of all the documents.
        """
        try:
            database_connection = self.__mongo_OBJ__[database_name]
            mongo_response = database_connection[collection_name].find(json_data, keyword_dict)
            mongo_response = self.fetch_records_from_object(mongo_response_obj=mongo_response,
                                                            collection_name=collection_name)
            logger.debug("Fetched results from mongo")
            return mongo_response
        except Exception as e:
            traceback.print_exc()
            logger.error("Error in finding document: " + str(e))
            raise MongoFindException(MONGO003)

    def remove(self, json_data, database_name, collection_name):
        """
        To delete document from collection
        :param json_data: The key/ value pair based on which the documents should be deleted.
        :param database_name: The database to which the collection/ documents belongs to.
        :param collection_name: The collection to which the documents belongs to.
        :return: Boolean value.
        """
        try:
            database_connection = self.__mongo_OBJ__[database_name]
            mongo_response = database_connection[collection_name].remove(json_data)
            logger.debug("Deleted document from mongo. Message: {}".format(mongo_response))
            return True
        except Exception as e:
            traceback.print_exc()
            logger.error("Error in deleting document: " + str(e))
            raise MongoDeleteException(MONGO004)

    def update_one(self, condition, json_data, database_name, collection_name):
        """
        To update single document
        :param condition: The condition by which the documents should be updated.
        :param json_data: The JSON data that should replace the previous document.
        :param database_name: The database to which the collection/ documents belongs to.
        :param collection_name: The collection to which the documents belongs to.
        :return: success
        """
        try:
            database_connection = self.__mongo_OBJ__[database_name]
            json_data = self.encrypt_data(json_data=json_data, collection_name=collection_name)
            database_connection[collection_name].update_one(condition, {"$set": json_data})
            logger.debug("Updated document from mongo")
            return True
        except Exception as e:
            traceback.print_exc()
            logger.error("Error in updating document: " + str(e))
            raise MongoUpdateException(MONGO005)

    def aggregate_query(self, json_data, database_name, collection_name):
        """
        To search using aggregate query
        :param json_data:
        :param database_name: The database to which the collection/ documents belongs to.
        :param collection_name: The collection to which the documents belongs to.
        :return: response object
        """
        try:
            database_connection = self.__mongo_OBJ__[database_name]
            mongo_response = database_connection[collection_name].aggregate(json_data)
            logger.debug("Fetched results from mongo")
            return mongo_response
        except Exception as e:
            traceback.print_exc()
            logger.error("Error in aggreation query: " + str(e))
            raise MongoQueryException(MONGO006)

    def close_connection(self):
        """
        To close the mongo connection
        :return:
        """
        try:
            if self.__mongo_OBJ__ is not None:
                self.__mongo_OBJ__.close()
            logger.debug("Mongo connection closed")
        except Exception as e:
            traceback.print_exc()
            logger.error("Error during closing of connection: " + str(e))
            raise MongoConnectionException(MONGO007)

    def find_item_containing_key_in_sub_json_object(self, condition_array, database_name, collection_name):
        """
        This function return item which contains provided JSON key inside sub json of mongodb record.
        :param: condition_array:
        :param: database_name: The database to which the collection/ documents belongs to.
        :param: collection_name: The collection to which the documents belongs to.
        :return: This function return item which contains provided JSON key inside sub json of mongodb record.
        """
        try:
            database_connection = self.__mongo_OBJ__[database_name]
            mongodb_response = database_connection[collection_name].find({"$or": condition_array})
            mongodb_response = list(mongodb_response)[0]
            # mongo_response = self.fetch_records_from_object(body=mongodb_response, collection_name=collection_name)
            return mongodb_response
        except Exception as e:
            traceback.print_exc()
            logger.error("Error in finding document: " + str(e))
            raise MongoFindException(MONGO003)

    def find_item_containing_key_in_sub_json_object_list(self, condition_array, database_name, collection_name):
        """
        This function return item which contains provided JSON key inside sub json of mongodb record.
        :param: condition_array:
        :param: database_name: The database to which the collection/ documents belongs to.
        :param: collection_name: The collection to which the documents belongs to.
        :return: This function return item which contains provided JSON key inside sub json of mongodb record.
        """
        try:
            database_connection = self.__mongo_OBJ__[database_name]
            mongodb_response = database_connection[collection_name].find({"$or": condition_array})
            mongodb_response = list(mongodb_response)
            # mongo_response = self.fetch_records_from_object(body=mongodb_response, collection_name=collection_name)
            return mongodb_response
        except Exception as e:
            traceback.print_exc()
            logger.error("Error in finding document: " + str(e))
            raise MongoFindException(MONGO003 + " " + str(e))

    def search_record_by_query(self, db_name, collection_name, query_json):
        """
        Definition for searching the record by query json
        :param db_name: The database to which the collection/ documents belongs to.
        :param collection_name: The collection to which the documents belongs to.
        :param query_json:
        :return:
        """
        mg_response = {}
        try:
            response = {}
            docid = self.__mongo_OBJ__[db_name][collection_name]
            for key, value in query_json.items():
                response = docid.find({key: value})
            mg_response = self.fetch_records_from_object(response, collection_name=collection_name)
        except Exception as es:
            logger.exception(es)
        return mg_response

    def fetch_records_from_object(self, mongo_response_obj, collection_name):
        """
        Fetches all the records from a Mongo object, decrypts them and returns and list of the documents.
        :param mongo_response_obj: The Mongo response object that should be decrypted before returning.
        :param collection_name: The collection to which the documents belongs to.
        :return: List of all the decrypted documents.
        """
        final_list = []
        try:
            for doc in mongo_response_obj:
                final_json = doc
                final_json = self.decrypt_data(encrypted_doc=final_json, collection_name=collection_name)
                final_list.append(final_json)
        except Exception as e:
            logger.exception(e)
        return final_list

    @staticmethod
    def object_id_deserializer(result_dict: dict):
        """
        Definition for de-serializing object of type ObjectID found in results retrieved from Mongo DB
        :param result_dict:
        :return:
        """
        try:
            for key, value in result_dict.items():
                if isinstance(value, ObjectId):
                    result_dict[key] = str(value)
            return result_dict
        except Exception as e:
            logger.error('Encountered error while de-serializing object id: {}'.format(e))
            raise

    def find_count(self, json_data, database_name, collection_name):
        """

        :param json_data:
        :param database_name: The database to which the collection/ documents belongs to.
        :param collection_name: The collection to which the documents belongs to.
        :return:
        """
        try:
            db = self.__mongo_OBJ__[database_name]
            mongo_response = db[collection_name].find(json_data).count()
            logger.debug("fetched result count from mongo")
            return mongo_response
        except Exception as e:
            traceback.print_exc()
            logger.error("Error in finding document: " + str(e))
            raise MongoFindException(MONGO003)

    def skip_docs(self, json_data, database_name, collection_name, condition_json, skip):
        """

        :param json_data:
        :param database_name: The database to which the collection/ documents belongs to.
        :param collection_name: The collection to which the documents belongs to.
        :param condition_json:
        :param skip:
        :return:
        """
        try:
            db = self.__mongo_OBJ__[database_name]
            mongo_response = db[collection_name].find(json_data).sort(condition_json).skip(skip)

            logger.debug("Fetched results from mongo")
            return mongo_response
        except Exception as e:
            traceback.print_exc()
            logger.error("Error in finding document: " + str(e))
            raise MongoFindException(MONGO003)

    def encrypt_unencrypted_docs(self, _database_name):
        """
        This method encrypts all the documents in configured collections that are already present in MongoDB.
        :param _database_name: The database to which the collection/ documents belongs to.
        :return: True/ False based on the outcome of the process.
        """
        try:
            for collection in encrypt_collection_dict.keys():
                all_docs = self.find_all_unencrypted(database_name=_database_name,
                                                     collection_name=collection)
                for doc in all_docs:
                    if product_encrypted in doc and doc[product_encrypted]:
                        logger.debug("Document already encrypted by Product. Skipping...")
                        continue
                    if "_id" in doc:
                        _id = doc["_id"]
                        del doc["_id"]
                    else:
                        logger.warning("'_id' not found in document. Skipping...")
                        continue
                    logger.debug("Updating document with Product encryption")
                    try:
                        self.update_one(condition={"_id": _id},
                                        json_data=doc,
                                        database_name=_database_name,
                                        collection_name=collection)
                    except Exception as e:
                        logger.error("Server faced problem when encrypting document with ID {} in collection {} -> {}".
                                     format(_id, collection, str(e)))
                        continue
                    logger.debug("Document updated with MLens encryption")
            return True
        except Exception as e:
            logger.error("Server faced a problem when encrypting existing metadata to MLens encryption formats --> {}".
                         format(str(e)))
            return False
