import os
import pandas as pd

from utils.databases.superclasses.database_superclasses import DbYamlInteractor, Database
from utils.databases.superclasses.hmm_db_superclass import HmmDatabase
from utils.databases.superclasses.fasta_db_superclass import FastaDatabase

from utils.exceptions import DatabaseError, DatabaseOutdatedError
from utils.vars import REQUIRED_DB_SCHEMA_VERSION
from utils.databases.ncbi.ncbi_accession2taxid import NcbiAcc2TaxDatabase
from utils.databases.eggnog.eggnog import EggnogDatabaseV4, EggnogDatabaseV5
from utils.databases.ncbi.ncbi_nr import NcbiNrDatabase
from utils.databases.pfams.pfams import PfamsDatabase
from utils.databases.ncbi.ncbi_taxdump import NcbiTaxdumpDatabase
from utils.databases.tigrfams.tigrfams import TigrfamsDatabase
from utils.databases.uniprot.uniprot import UniprotCompleteDatabase, UniprotSwissprotDatabase, UniprotTremblDatabase, UniprotTaxDatabase
from utils.databases.foam.foam import FoamDatabase
from utils.databases.resfams.resfams import ResfamsCoreDatabase, ResfamsFullDatabase
from utils.databases.dbcan.dbcan import DbcanDatabase

LST_SUPPORTED_DBS = [
    EggnogDatabaseV4,
    EggnogDatabaseV5,
    NcbiNrDatabase,
    NcbiTaxdumpDatabase,
    NcbiAcc2TaxDatabase,
    PfamsDatabase,
    TigrfamsDatabase,
    UniprotCompleteDatabase,
    UniprotSwissprotDatabase,
    UniprotTremblDatabase,
    UniprotTaxDatabase,
    FoamDatabase,
    ResfamsCoreDatabase,
    ResfamsFullDatabase,
    DbcanDatabase
]

LST_ANNOTATION_DB_CLASSES = [
    EggnogDatabaseV4,
    EggnogDatabaseV5,
    FastaDatabase,
    HmmDatabase
]


class Databases:
    def __init__(self, yamls):
        if isinstance(yamls, str):
            yamls = [yamls]
        self._db_obj_dict = self._build_db_obj_dict(yamls)

    def __iter__(self):
        return iter(list(self._db_obj_dict.values()))

    def _build_db_cfg_dict(self, yamls):
        cfg_dct = {}
        for yaml in yamls:
            cfg_dct[yaml] = DbYamlInteractor.get_db_config_dict_from_yaml(yaml)
        return cfg_dct

    def _check_for_outdated_db_schema_versions(self, dct_cfg_files):
        outdated_db_yamls = []
        for f, cfg_dct in dct_cfg_files.items():
            if DbYamlInteractor(f, cfg_dct).is_db_schema_version_outdated():
                outdated_db_yamls.append(f)

        if outdated_db_yamls:
            raise DatabaseOutdatedError(
                f"Database Schema of the following yamls is lower than required (min: {REQUIRED_DB_SCHEMA_VERSION}).\n"
                + "Please execute Database migration.\n"
                + "\n".join(outdated_db_yamls)
            )

    def _build_db_obj_dict(self, yamls):
        """
        Recursively initialize the DB objects from the provided yamls. For each yaml, DB initialization is tried. If
        a DB requires DBs that are not initialized yet, the respective yaml is put in a waiting list and initialization
        is continued with the next yaml. After trying all yamls, iteration starts again with the
        waiting list, trying to satisfy their requirements with the previously initialized DBs. If the waiting list
        is empty, result is returned. To avoid infinite loops for DBs with requirements that cannot be met,
        the maximum recursion depth is set to 3. Raises a DatabaseError if the waiting list is not empty after 3 tries.

        :param yamls: list of yaml paths
        :return: dict, key: yaml path, value: db_object
        """
        max_dependency_dependency_tree_depth = 3
        # key: yaml; value: cfg_dct
        dct_cfg_files = self._build_db_cfg_dict(yamls)
        self._check_for_outdated_db_schema_versions(dct_cfg_files)

        dct_db_objs = {}
        for i in range(max_dependency_dependency_tree_depth):
            dct_delayed_cfg_files = {}
            dct_new_db_objs = {}
            for yaml_path, cfg_dct in dct_cfg_files.items():
                db_type = Database.get_type_from_db_config_dict(cfg_dct)
                db_cls = self.get_db_class_for_type(db_type)
                if db_cls.is_requiring_helper_dbs():
                    lst_of_tuples_of_required_dbs = db_cls.get_lst_of_tuples_of_required_dbs(cfg_dct)
                    try:
                        lst_required_db_objs = \
                            self.get_required_db_objs_from_lst(dct_db_objs, lst_of_tuples_of_required_dbs)
                    except DatabaseError:
                        dct_delayed_cfg_files[yaml_path] = cfg_dct
                    else:    # if try is successfull, initialize db obj requiring helper DBs
                        dct_new_db_objs[yaml_path] = db_cls(yaml=yaml_path, db_yaml_dict=cfg_dct,
                                                            required_dbs=lst_required_db_objs)
                else:
                    dct_new_db_objs[yaml_path] = db_cls(yaml_path, db_yaml_dict=cfg_dct)
            dct_db_objs.update(dct_new_db_objs)
            dct_cfg_files = dct_delayed_cfg_files

        if len(dct_cfg_files) > 0:
            raise DatabaseError(
                "Not all databases could be initialized with a maximum dependency tree depth of "
                f"{max_dependency_dependency_tree_depth}\n"
                + "Databases, whose required DBs are not available:\n"
                + "\n".join(dct_cfg_files.keys())
                                )
        return dct_db_objs

    def get_required_db_objs_from_lst(self, dct_db_objs, lst_of_tuples_of_required_dbs):
        lst_required_db_objs = []
        for helper_db_type, helper_db_version in lst_of_tuples_of_required_dbs:
            required_db_obj = self.get_database_obj_for_type_and_version(
                db_type=helper_db_type, version=helper_db_version, dct_db_objs=dct_db_objs)
            lst_required_db_objs.append(required_db_obj)
        return lst_required_db_objs

    @staticmethod
    def get_db_class_for_type(db_type):
        lst_fitting_db_classes = []
        for db_cls in LST_SUPPORTED_DBS:
            if db_cls.is_valid_type(db_type):
                lst_fitting_db_classes.append(db_cls)
        if len(lst_fitting_db_classes) == 1:
            db_cls_accepting_db_type = lst_fitting_db_classes[0]
        elif len(lst_fitting_db_classes) > 1:
            cls_names = [cls.name for cls in lst_fitting_db_classes]
            raise ValueError(
                f"Multiple classes accept db_type '{db_type}'.\n"
                + "Accepted type of each db class must be unique. Non-unique classes:\n"
                + "\n".join(cls_names)
            )
        else:  # len(lst_fitting_db_classes) == 0
            raise DatabaseError(
                f"No database class accepts db_type: '{db_type}'\n"
                + "valid types are:\n"
                + "\n".join(sorted([t for db_cls in LST_SUPPORTED_DBS for t in db_cls.valid_types]))
            )
        return db_cls_accepting_db_type

    def get_database_obj_for_type_and_version(self, db_type, version, dct_db_objs=None):
        if dct_db_objs is None:
            dct_db_objs = self._db_obj_dict
        lst_fitting_objs = []
        for db_o in dct_db_objs.values():
            if (version == db_o.get_version()
                    and db_type == db_o.get_type()):
                lst_fitting_objs.append(db_o)
        if len(lst_fitting_objs) < 1:
            raise DatabaseError("No Database can satisfy demands:\n"
                                + f"db_type: {db_type}\n"
                                + f"db_version: {version}\n"
                                "Available databases are:\n"
                                + "\n".join([str(o) for o in dct_db_objs.values()]))
        elif len(lst_fitting_objs) > 1:
            raise DatabaseError("More than one Database can satisfy demands:\n"
                                + f"db_type: {db_type}\n"
                                + f"db_version: {version}\n"
                                + "\n".join([str(o) for o in lst_fitting_objs]) + "\n")
        else:
            db_obj = lst_fitting_objs[0]
        return db_obj

    def get_db_objs_matching_db_types(self, lst_of_db_types):
        matching_yaml_objs = []
        for db_obj in self.get_db_objects():
            if db_obj.get_type() in lst_of_db_types:
                matching_yaml_objs.append(db_obj)
        return matching_yaml_objs

    def get_one_db_obj_matching_db_type(self, db_type):
        lst_y_objs = self.get_db_objs_matching_db_types([db_type])
        if len(lst_y_objs) != 1:
            raise ValueError(f"Expected exactly one yaml object with type '{db_type}', \n"
                             + f"got: {len(lst_y_objs)}\n"
                             + "available DBs:\n"
                             + "\n\n".join(map(str, self.get_db_objects())))
        return lst_y_objs[0]

    def get_db_objects(self):
        return list(self._db_obj_dict.values())

    def get_db_obj_for_yaml(self, yaml):
        return self._db_obj_dict[yaml]

    @staticmethod
    def get_supported_db_types():
        lst_db_names = []
        for db_cls in LST_SUPPORTED_DBS:
            lst_db_names.extend(db_cls.get_valid_types())
        return sorted(lst_db_names)


def get_database_info_df(yamls, db_base_dir):
    """
    Build table of all databases, one database per row. Columns are 'db_type', 'scope', 'db_version', and 'db_yaml'.
    Column 'db_yaml' is the db_config.yaml path, relative to db_base_dir.
    :param yamls: list of paths or DbYamlInteractor objects
    :param db_base_dir: database base dir
    :return: pandas.DataFrame
    """
    dbs = []
    if not yamls:
        pass
    elif all([isinstance(y, DbYamlInteractor) for y in yamls]):
        dbs = Databases([y.get_yaml() for y in yamls])
    elif all(os.path.exists(y) for y in yamls):
        dbs = Databases([y for y in yamls])
    else:
        raise DatabaseError("Parameter yamls must contain only paths or only DbYamlInteractor objects")
    df = pd.DataFrame(columns=['db_type', 'scope', 'db_version', 'db_yaml'])
    for db in dbs:
        s = pd.Series(dtype="object")
        s['db_type'] = db.get_type()
        s['scope'] = db.get_scope()
        s['db_version'] = db.get_version()
        rel_yaml_path = os.path.relpath(os.path.abspath(db.get_yaml()), db_base_dir)
        s['db_yaml'] = rel_yaml_path
        df = df.append(s, ignore_index=True)
    return df
