""" Provides an interface to interact with a database. """
#  PASTA-ELN and all its sub-parts are covered by the MIT license.
#
#  Copyright (c) 2024
#
#  Author: Jithu Murugan
#  Filename: base_database_api.py
#
#  You should have received a copy of the license with this file. Please refer the license file for more information.
import logging
from threading import Lock
from typing import Any

from cloudant import couchdb
from cloudant.design_document import DesignDocument
from cloudant.document import Document
from cloudant.error import CloudantDatabaseException
from cloudant.view import View

from pasta_eln.dataverse.config_error import ConfigError
from pasta_eln.dataverse.database_error import DatabaseError
from pasta_eln.dataverse.utils import log_and_create_error, read_pasta_config_file


class BaseDatabaseAPI:
  """
  Provides an interface to interact with a database.

  Explanation:
      This class encapsulates the functionality to interact with a database, including creating, retrieving, and updating documents,
      adding views to design documents, and getting view results.

  """

  def __init__(self) -> None:
    """
    Initializes the BaseDatabaseAPI instance.

    Explanation:
        This method initializes the BaseDatabaseAPI instance by setting up the necessary attributes and loading the configuration file.

    Args:
        None

    Raises:
        DatabaseError: If the config file or required fields are not found.

    Returns:
        None
    """
    super().__init__()
    self.password = None
    self.username = None
    self.host = 'localhost'
    self.port = 5984
    self.url = f'http://{self.host}:{self.port}'
    self.logger = logging.getLogger(f"{__name__}.{self.__class__.__name__}")
    config = read_pasta_config_file(self.logger)
    if 'defaultProjectGroup' in config:
      self.db_name = config['defaultProjectGroup']
    else:
      raise log_and_create_error(self.logger, ConfigError, "Incorrect config file, defaultProjectGroup not found!")
    self.set_username_password(config)
    self.update_lock = Lock()

  def set_username_password(self, config: dict[str, Any]) -> None:
    """
    Sets the username and password based on the provided config.

    Args:
        config (dict[str, Any]): The configuration dictionary.

    Raises:
        DatabaseError: If the config file or required fields are not found.

    """
    project_groups = config.get('projectGroups')
    if not project_groups:
      raise log_and_create_error(self.logger, ConfigError,
                                 "Incorrect config file, projectGroups not found!")
    main_group = project_groups.get(self.db_name)
    if not main_group:
      raise log_and_create_error(self.logger, ConfigError,
                                 "Incorrect config file, defaultProjectGroup not found!")
    local_info = main_group.get('local')
    if not local_info:
      raise log_and_create_error(self.logger, ConfigError,
                                 "Incorrect config file, user or password not found!")
    self.username = local_info.get('user')
    self.password = local_info.get('password')

  def create_document(self, data: dict[str, Any]) -> Document:
    """
    Creates a document in the database with the provided data.

    Args:
        data (dict[str, Any]): The data to be stored in the document.

    Returns:
        Document: The created document.

    """
    self.logger.info("Creating document with data: %s", data)
    with couchdb(self.username,
                 self.password,
                 url=self.url,
                 connect=True) as client:
      pasta_db = client[self.db_name]
      document = None
      try:
        document = pasta_db.create_document(data, throw_on_exists=True)
      except CloudantDatabaseException as e:
        self.logger.error("Error creating document: %s", e)
      return document

  def get_document(self, document_id: str) -> Document:
    """
    Retrieves a document from the database based on the provided document ID.

    Args:
        document_id (str): The ID of the document to retrieve.

    Returns:
        Document: The retrieved document.

    Raises:
        DatabaseError: If the document ID is empty or if an error occurs during retrieval.

    """
    self.logger.info("Retrieving document with id: %s", document_id)
    if not document_id:
      raise log_and_create_error(self.logger, DatabaseError, "Document ID cannot be empty!")
    with couchdb(self.username,
                 self.password,
                 url=self.url,
                 connect=True) as client:
      pasta_db = client[self.db_name]
      document = None
      try:
        document = pasta_db[document_id]
      except KeyError as e:
        self.logger.error("Error retrieving document: %s", e)
      return document

  def update_document(self, data: dict[str, Any]) -> None:
    """
    Updates a document in the database with the provided data.
    Make sure to use the `_id` and `_rev` keys in the data to identify the document to update otherwise a new document will be created.

    Args:
        data (dict[str, Any]): The data to update the document with.

    """
    self.logger.info("Updating document with data: %s", data)
    with self.update_lock:
      with couchdb(self.username,
                   self.password,
                   url=self.url,
                   connect=True) as client:
        pasta_db = client[self.db_name]
        with Document(pasta_db, data['_id']) as document:
          for key, value in data.items():
            if key not in ['_id', '_rev']:
              document[key] = value

  def add_view(self,
               design_document_name: str,
               view_name: str,
               map_func: str,
               reduce_func: str | None = None) -> None:
    """
    Adds a view to the specified design document in the database.

    Args:
        design_document_name (str): The name of the design document.
        view_name (str): The name of the view.
        map_func (str): The map function for the view.
        reduce_func (str, optional): The reduce function for the view. Defaults to None.

    Raises:
        ValueError: If design_document_name, view_name, or map_func is None.

    Examples:
        # Add a view with a map function
        add_view("my_design_doc", "my_view", "function(doc) { emit(doc._id, 1); }")

        # Add a view with a map and reduce function
        add_view("my_design_doc", "my_view", "function(doc) { emit(doc.name, doc.age); }", "_count")
    """
    self.logger.info("Adding view: %s to design document: %s, map_func: %s, reduce_func: %s",
                     view_name,
                     design_document_name,
                     map_func,
                     reduce_func)
    if design_document_name is None or view_name is None or map_func is None:
      raise log_and_create_error(self.logger, DatabaseError,
                                 "Design document name, view name, and map function cannot be empty!")
    with couchdb(self.username,
                 self.password,
                 url=self.url,
                 connect=True) as client:
      pasta_db = client[self.db_name]
      with DesignDocument(pasta_db, design_document_name) as design_doc:
        design_doc.add_view(view_name, map_func, reduce_func)

  def get_view(self,
               design_document_name: str,
               view_name: str) -> View:
    """
    Retrieves a view from the design document.

    Explanation:
        This method retrieves a view from the specified design document.
        It connects to the CouchDB instance, selects the appropriate database,
        and retrieves the view from the design document.

    Args:
        self: The DatabaseAPI instance.
        design_document_name (str): The name of the design document.
        view_name (str): The name of the view.

    Raises:
        ValueError: If the design document name or view name is empty.

    Returns:
        View: The retrieved view.
    """
    self.logger.info("Retrieving view: %s from design document: %s",
                     view_name,
                     design_document_name)
    if design_document_name is None or view_name is None:
      raise log_and_create_error(self.logger, DatabaseError, "Design document name, view name cannot be empty!")
    with couchdb(self.username,
                 self.password,
                 url=self.url,
                 connect=True) as client:
      pasta_db = client[self.db_name]
      with DesignDocument(pasta_db, design_document_name) as design_doc:
        return design_doc.get_view(view_name)

  def get_view_results(self,
                       design_document_name: str,
                       view_name: str,
                       map_func: str | None = None,
                       reduce_func: str | None = None) -> list[dict[str, Any]]:
    """
    Gets the results of a view from the specified design document in the database.

    Args:
        design_document_name (str): The name of the design document.
        view_name (str): The name of the view.
        map_func (str, optional): The map function for the view. Defaults to None.
        reduce_func (str, optional): The reduce function for the view. Defaults to None.

    Raises:
        ValueError: If design_document_name or view_name is None.

    Returns:
        list[dict[str, Any]]: The results of the view as a list of dictionaries.

    Examples:
        # Get results from a view without map and reduce functions
        results = get_view_results("my_design_doc", "my_view")

        # Get results from a view with map and reduce functions
        results = get_view_results("my_design_doc", "my_view", "function(doc) { emit(doc._id, 1); }", "_count")
    """
    self.logger.info("Getting view results: %s from design document: %s, map_func: %s, reduce_func: %s",
                     view_name,
                     design_document_name,
                     map_func,
                     reduce_func)
    if design_document_name is None or view_name is None:
      raise log_and_create_error(self.logger, DatabaseError, "Design document name and view name cannot be empty!")
    with couchdb(self.username,
                 self.password,
                 url=self.url,
                 connect=True) as client:
      pasta_db = client[self.db_name]
      results: list[dict[str, Any]] = []
      with DesignDocument(pasta_db, design_document_name) as design_doc:
        view = View(design_doc, view_name, map_func, reduce_func)
        results.extend(result['value'] for result in view.result)
        return results

  def get_view_results_by_id(self,
                             design_document_name: str,
                             view_name: str,
                             document_id: str,
                             map_func: str | None = None,
                             reduce_func: str | None = None) -> Document | None:
    """
    Gets the result of a view for a specific document ID from the specified design document in the database.

    Args:
        design_document_name (str): The name of the design document.
        view_name (str): The name of the view.
        document_id (str): The ID of the document.
        map_func (str | None, optional): The map function for the view. Defaults to None.
        reduce_func (str | None, optional): The reduce function for the view. Defaults to None.

    Raises:
        ValueError: If design_document_name, view_name, or document_id is None.

    Returns:
        Document | None: The result of the view for the specified document ID, or None if not found.

    Examples:
        # Get the result of a view for a specific document ID
        result = get_view_results_by_id("my_design_doc", "my_view", "my_document_id")

        # Get the result of a view for a specific document ID with map and reduce functions
        result = get_view_results_by_id("my_design_doc", "my_view", "my_document_id", "function(doc) { emit(doc._id, 1); }", "_count")
    """
    self.logger.info("Getting view result: %s for id: %s from design document: %s, map_func: %s, reduce_func: %s",
                     view_name,
                     document_id,
                     design_document_name,
                     map_func,
                     reduce_func)
    with couchdb(self.username,
                 self.password,
                 url=self.url,
                 connect=True) as client:
      if design_document_name is None or view_name is None or document_id is None:
        raise log_and_create_error(self.logger, DatabaseError,
                                   "Design document name, view name and document id cannot be empty!")
      pasta_db = client[self.db_name]
      with DesignDocument(pasta_db, design_document_name) as design_doc:
        view = View(design_doc, view_name, map_func, reduce_func)
        return view.result[document_id][0]['value']
