import datetime
import glob
import os

from copy import deepcopy

from typing import List, Tuple

from utils import REQUIRED_DB_SCHEMA_VERSION, DB_RESSOURCE_DIR
from utils.databases.superclasses.database_superclasses import DbYamlInteractor
from utils.databases import Databases
from utils.databases.api_class import get_database_info_df, LST_SUPPORTED_DBS, LST_ANNOTATION_DB_CLASSES
from utils.exceptions import DatabaseAccessorKeyError, DatabaseError
from utils.helper_funcs import get_max_version
from utils.input_output import load_yaml

__DATABASES_CACHE = None
__DB_BASE_DIR_CACHE = ""


def _get_cached_databases(db_base_dir, force_rebuild=False):
    global __DATABASES_CACHE, __DB_BASE_DIR_CACHE
    if (not __DATABASES_CACHE) or (db_base_dir != __DB_BASE_DIR_CACHE) or force_rebuild:
        yamls = get_db_config_yamls_in_path(db_base_dir)
        __DB_BASE_DIR_CACHE = db_base_dir
        __DATABASES_CACHE = Databases(yamls)
    return __DATABASES_CACHE


def is_db_yaml_outdated(db_yaml) -> bool:
    db = DbYamlInteractor(db_yaml)
    return db.is_db_schema_version_outdated()


def get_db_config_yamls_in_path(path=""):
    """
    recursively searches 'path' for files matching '*.yaml'
    :param path: string, path that is searched
    :return: list of strings, found yamls
    """
    return [f for f in glob.glob(os.path.join(path, "*/*/db_config.yaml"))]


def get_date_yyyy_mm_dd():
    """
    Get current date. If environment variable 'MOCKED_DATE' is set, return it's value.
    :return: str, format: yyyy-mm-dd
    """
    if 'MOCKED_DATE' in os.environ:
        env_str = os.environ['MOCKED_DATE']
        try:
            datetime.datetime.fromisoformat(env_str)
            date = env_str
        except ValueError as e:
            raise ValueError(f"Invalid datetime string in environment variable MOCKED_DATE: {env_str}\n"
                             "Required format: yyyy-mm-dd (e.g. 2020-12-31")
    else:
        date = datetime.datetime.now().strftime("%Y-%m-%d")
    return date


class DbAccessor(object):
    def __init__(self, db_path, force_rebuild=False):
        """
        Easy access to all databases within db_path. To decrease IO-overhead, db_path is only searched for Databases
        upon first initialization of DbAccessor and results are cached for all future calls. Rebuild can be forced
        using parameter force_rebuild.

        :param db_path: str, path is searched for db_config.yaml files matching the pattern
        `*/*/db_config.yaml`
        :param force_rebuild: bool, default: False, force searching for db_config.yaml files instead of using cached
        search results
        """
        self.db_base_path = db_path
        self.dbs = _get_cached_databases(self.db_base_path, force_rebuild)
        self._df_database_infos = self._build_df_database_infos()

    def __str__(self):
        df = self._df_database_infos.sort_values(by=['scope', 'db_type']).copy()
        msg = ""
        if df.empty:
            msg += f'No databases available in "{self.get_db_base_path()}":\n'
        else:
            msg = 'Databases available in "{}":\n'.format(self.get_db_base_path())
            df['db_yaml'] = df['db_yaml'].apply(lambda x: os.path.relpath(os.path.abspath(x), self.get_db_base_path()))
            msg += str(df[['scope', 'db_type', 'db_version', 'db_yaml']].to_string(index=False))
        msg += 'Databases available for download are:\n'
        msg += "\n".join(sorted([f"- {db}; versions: {', '.join(versions)}"
                                 for (db, versions) in self.get_downloadable_databases()]))
        return msg

    def _build_df_database_infos(self):
        """
        return df: one column with all paths to all db_setup.yaml files
        """
        # self.dbs.get_db_objects(): all dbs objects in base_dir
        df = get_database_info_df(self.dbs.get_db_objects(), db_base_dir=self.db_base_path)
        df["db_yaml"] = df["db_yaml"].apply(lambda x: os.path.join(self.db_base_path, x))
        return df

    def _is_db_obj_in_df(self, db_type, version):
        db_exists = False
        df = self._df_database_infos
        if (df['db_type'] == db_type).any():
            df_db_type = df[df['db_type'] == db_type]
            if version == 'newest':
                db_exists = True
            elif (df_db_type['db_version'] == version).any():
                db_exists = True
        return db_exists

    def _is_db_obj_creatable(self, db_type, db_version):
        try:
            Databases.get_db_class_for_type(db_type)
        except DatabaseError:
            return False
        if db_version == "newest":
            return True
        else:
            return (db_version in DbRequirementsAccessor(db_type, db_version).get_downloadable_version_strings())

    def get_db_base_path(self):
        return self.db_base_path

    @staticmethod
    def get_downloadable_databases() -> List[Tuple[str, list]]:
        lst = []
        for db in LST_SUPPORTED_DBS:
            if issubclass(db, tuple(LST_ANNOTATION_DB_CLASSES)):
                lst.append((db.name, DbRequirementsAccessor(db.name).get_downloadable_version_strings()))
        return lst

    def get_db_objects(self):
        return self.dbs.get_db_objects()

    def get_db_obj_for_yaml(self, yaml):
        return self.dbs.get_db_obj_for_yaml(yaml)


    def get_eggnog_db_type(self, version):
        if version == 'newest':
            if self._is_db_obj_in_df('eggnog_v5', version):
                db_type = 'eggnog_v5'
            elif self._is_db_obj_in_df('eggnog_v4', version):
                db_type = 'eggnog_v4'
            else:    # no local eggnog db, set type to newest possible eggnog db
                db_type = 'eggnog_v5'
        else:
            db_type = f"eggnog_v{int(version[0])}"
        return db_type

    def get_database_object(self, db_type, version='newest'):
        df = self._df_database_infos
        if db_type == 'eggnog': # from task config dict
            db_type = self.get_eggnog_db_type(version)
        # does db object exist?
        if self._is_db_obj_in_df(db_type, version):
            df = df[df['db_type'] == db_type]
            if version == 'newest':
                version = get_max_version(df['db_version'].values)
            db_obj = self.dbs.get_database_obj_for_type_and_version(db_type, version)
        # can db object be created?
        elif self._is_db_obj_creatable(db_type, version):
            if version == 'newest':
                version = DbRequirementsAccessor(db_type).get_most_recent_downloadable_version_string()
            db_obj = DbObjBuilder(db_type, version, self.db_base_path, check_required_file_presence=False).create()
        else:
            raise DatabaseAccessorKeyError(self, db_type=db_type, version=version)
        return db_obj

    def get_database_yaml(self, *args, **kwargs):
        obj = self.get_database_object(*args, **kwargs)
        yaml = obj.get_yaml()
        return yaml

    def print_summary(self):
        print('\n')
        print(self)
        pass

    @staticmethod
    def get_db_schema_version_required_by_prophane():
        return REQUIRED_DB_SCHEMA_VERSION


class DbRequirementsAccessor(object):
    """
    Unified access to DB specific requirements, like:
      - required files for each DB and their respective download links
      - required helper databases, if any
      - most recent available DB-version string
    """

    def __init__(self, db_name, requested_db_version=None):
        self._today = get_date_yyyy_mm_dd()
        if db_name == 'eggnog':
            self.db_name = self.__get_db_type_for_eggnog(requested_db_version)
        else:
            self.db_name = db_name
        self.db_cls = Databases.get_db_class_for_type(self.db_name)
        self.requested_db_version = requested_db_version
        self.resource_dir = os.path.join(DB_RESSOURCE_DIR, self.db_name.split('_')[0])
        self.resource_file = self.get_resource_file_for_db_type()
        self.resources = load_yaml(self.resource_file)
        self.db_version_key = self.__get_db_version_key()

    def __get_db_type_for_eggnog(self, requested_db_version):
        if requested_db_version is None:
            return "eggnog_v5"
        elif isinstance(requested_db_version, str) and (requested_db_version[0] in ["4", "5"]):
            return f"eggnog_v{requested_db_version[0]}"
        else:
            raise ValueError(f"Eggnog database of the specified version not available: {self.requested_db_version}")

    def __get_db_version_key(self):
        # __get_available_version_keys = +
        # e.g.: self.db_name = eggnog_v4, self.__get_available_verions_keys=['4.5.1'], self.requested_db_version='4.5.1'
        if self.requested_db_version is None:
            key = self._get_most_recent_downloadable_version_key()
        else:
            if self.requested_db_version in self.__get_available_version_keys():
                key = self.requested_db_version
            elif self.requested_db_version == self._today:
                key = "current_date"
            else:
                raise ValueError(f"No key associated to db_version {self.requested_db_version}\n"
                                 f"in database resource file {self.resource_file}")
        return key

    def _assert_version_is_valid(self):
        if self.requested_db_version is None:
            raise AttributeError("Please specifiy a version to get the required files as these can change between "
                                 "versions.")
        available_versions = self.get_downloadable_version_strings()
        assert self.requested_db_version in available_versions, \
            (f"Cannot satisfy version requirement: '{self.requested_db_version}'\n"
             f"available versions: {available_versions}")

    def __get_available_version_keys(self):
        versions = self.resources["versions"].keys()
        versions = [str(v) for v in versions]
        return versions

    def _get_most_recent_downloadable_version_key(self):
        versions = self.__get_available_version_keys()
        if "current_date" in versions:
            return "current_date"
        else:
            return get_max_version(versions)

    def get_downloadable_version_strings(self):
        versions = self.__get_available_version_keys()
        versions = [self._today if v == "current_date" else v for v in versions]
        return versions

    def get_most_recent_downloadable_version_string(self):
        most_recent_version_key = self._get_most_recent_downloadable_version_key()
        if most_recent_version_key == "current_date":
            return self._today
        else:
            return most_recent_version_key

    def is_requiring_helper_dbs(self):
        return self.db_cls.is_requiring_helper_dbs()

    def get_file_for_key(self, key):
        """
        Return path to required Database resource file, joined with download method name as subdir.

        :param key: key in 'files' section in db_config.yaml
        :return:
        """
        f = ""
        method = ""
        available_keys = [d["yaml_file_key"] for d in self.resources['versions'][self.db_version_key]]
        if key not in available_keys:
            raise KeyError(f"File key not found in {self.resource_file} for version {self.db_version_key}\n"
                                       f"Key: {key}\n")
        for file_url_dct in self.resources['versions'][self.db_version_key]:
            if key == file_url_dct["yaml_file_key"]:
                f = file_url_dct['file']
                if 'url' in file_url_dct:
                    method = 'direct_dl'
                else:
                    try:
                        method = file_url_dct['dl_method']
                    except KeyError:
                        raise KeyError(f"Missing key for version {self.db_version_key} in {self.resource_file}\n"
                                       f"Expected 'url' or 'dl_method' entry for key {key}\n"
                                       "Found none.")
        return os.path.join(method, f)

    def get_required_downloadable_files_in_method_subdirs(self):
        self._assert_version_is_valid()
        files = []
        for file_url_dct in self.resources['versions'][self.db_version_key]:
            file_key = file_url_dct["yaml_file_key"]
            files.append(self.get_file_for_key(file_key))
        return files

    def get_url_for_file(self, filename):
        self._assert_version_is_valid()
        urls = [file_url_dct['url'] for file_url_dct in self.resources['versions'][self.db_version_key]
                if filename == file_url_dct['file']]
        if len(urls) != 1:
            raise ValueError(f"expected a single url for db version {self.db_version_key} for filename {filename}\n"
                             f"in {self.resource_file}\n"
                             f"found: {len(urls)}")
        return urls[0]

    def get_resource_file_for_db_type(self):
        f = os.path.join(self.resource_dir, self.db_name + "_res.yaml")
        if not os.path.exists(f):
            raise FileNotFoundError(f"Required database resource file for db type '{self.db_name}' not found: \n"
                                    + f)
        return f

    def get_db_cfg_template_path(self):
        f = os.path.join(self.resource_dir, self.db_name + ".yaml.template")
        if not os.path.exists(f):
            raise FileNotFoundError(f"Required database resource file for db type '{self.db_name}' not found: \n"
                                    + f)
        return f

    def get_yaml_key_for_file(self, file_basename):
        self._assert_version_is_valid()
        lst_keys = [file_url_dct['yaml_file_key'] for file_url_dct in self.resources['versions'][self.db_version_key]
                    if file_basename == file_url_dct['file']]
        if len(lst_keys) != 1:
            raise ValueError(
                f"expected a single entry for db version {self.db_version_key} for filename {file_basename}"
                f"in {self.resource_file}"
                f"found: {len(lst_keys)}")
        return lst_keys[0]

    def get_required_dbs_and_versions(self):
        cfg_tmp_dct = load_yaml(self.get_db_cfg_template_path())
        dbs = cfg_tmp_dct["required_dbs"].keys()
        # dbs = self.db_cls.required_db_types[:]
        if self.requested_db_version is None:
            v = self.get_most_recent_downloadable_version_string()
        else:
            v = self.requested_db_version
        versions = len(dbs) * [v]
        return dbs, versions


class DbObjBuilder(object):
    """
    Write database specific config file (db_config.yaml)
    """

    def __init__(self, db_name, db_version, db_base_path, required_db_objects=None, build_required_db_objects=True,
                 check_required_file_presence=True):
        """
        Construct db_object based on specified parameters.

        :param db_name:
        :param db_version:
        :param required_db_objects:
        :param build_required_db_objects: bool
        :param check_required_file_presence: bool
        """
        self.db_base_path = db_base_path
        self.db_name = db_name
        self.db_version = db_version
        self.db_dir = os.path.join(db_base_path, 'eggnog', db_version) if 'eggnog' in db_name \
            else os.path.join(db_base_path, db_name, db_version)
        self.yaml_path = os.path.join(self.db_dir, "db_config.yaml")
        self.date_string = get_date_yyyy_mm_dd()
        self.db_req_accessor = DbRequirementsAccessor(self.db_name, self.db_version)
        self.db_cls = self.db_req_accessor.db_cls
        self.cfg_dct_template = load_yaml(self.db_req_accessor.get_db_cfg_template_path())
        self.cfg_dct = deepcopy(self.cfg_dct_template)
        # easy access to databases
        if build_required_db_objects:
            self.required_db_objs = self._build_required_db_objs(build_required_db_objects,
                                                                 check_required_file_presence)
        else:
            self.required_db_objs = required_db_objects if required_db_objects else []
        # populate cfg_dct with required values (file paths relative to yaml_out_path dir)
        self.add_files()
        if check_required_file_presence:
            self.check_files_exist()
        self.add_version()
        self.add_date()
        self.add_versions_required_dbs()

    @classmethod
    def build_with_existing_requirements(cls, yaml_out_path, required_db_yamls=None):
        db_dir = os.path.dirname(yaml_out_path)
        db_dir_parent, db_version = os.path.split(db_dir)
        db_base_path, db_name = os.path.split(db_dir_parent)
        db_type= db_name if db_name != 'eggnog' else f"{db_name}_v{db_version[0]}"
        # easy access to databases
        required_db_objects = Databases(required_db_yamls).get_db_objects() if required_db_yamls else []
        return cls(db_type, db_version, db_base_path, required_db_objects, build_required_db_objects=False)

    def _build_required_db_objs(self, build_required_db_objects, check_required_file_presence):
        req_dbs = []
        if self.db_cls.is_requiring_helper_dbs():
            helper_db_names, versions = self.db_req_accessor.get_required_dbs_and_versions()
            for db_name, ver in zip(helper_db_names, versions):
                db = self.__class__(db_name, ver, self.db_base_path,
                                    build_required_db_objects=build_required_db_objects,
                                    check_required_file_presence=check_required_file_presence).create()
                req_dbs.append(db)
        return req_dbs

    def add_files(self):
        required_keys = list(self.cfg_dct_template["files"].keys())
        for key in required_keys:
            f = self.db_req_accessor.get_file_for_key(key)
            self.cfg_dct["files"][key] = f

    def check_files_exist(self):
        missing_files = []
        for f_rel_to_yaml in self.cfg_dct['files'].values():
            f_path = os.path.join(self.db_dir, f_rel_to_yaml)
            if not os.path.exists(f_path):
                missing_files.append(f_path)
        if missing_files:
            raise FileNotFoundError(f"Missing required file(s): {missing_files}")

    def add_version(self):
        self.cfg_dct["version"] = self.db_version

    def add_versions_required_dbs(self):
        if self.db_cls.is_requiring_helper_dbs():
            if not self.required_db_objs:
                raise DatabaseError(
                    f"Constuction of {self.db_name} Database object, v{self.db_version} requires the following dbs:\n"
                    + "  - " + "\n  - ".join(self.cfg_dct["required_dbs"])
                )
            for db_key, ver_dict in self.cfg_dct["required_dbs"].items():
                matching_dbs = [db for db in self.required_db_objs if db.get_type() == db_key]
                if len(matching_dbs) == 0:
                    raise DatabaseError(f"Exactly one Database object of type {db_key} is required, got None.\n"
                                        "Available Database objects:\n"
                                        "\n".join(self.required_db_objs))
                elif len(matching_dbs) > 1:
                    raise DatabaseError(
                        f"Exactly one Database object of type {db_key} is required, got {len(matching_dbs)}.\n"
                        "Available Database objects:\n"
                        "\n".join(self.required_db_objs))
                req_db_obj = matching_dbs[0]
                ver_dict["version"] = req_db_obj.get_version()

    def add_date(self):
        comment = self.cfg_dct_template["comment"].format(date=self.date_string)
        self.cfg_dct["comment"] = comment

    def create(self):
        if self.db_cls.is_requiring_helper_dbs():
            db_obj = self.db_cls(yaml=self.yaml_path, db_yaml_dict=self.cfg_dct,
                                 required_dbs=self.required_db_objs)
        else:
            db_obj = self.db_cls(yaml=self.yaml_path, db_yaml_dict=self.cfg_dct)
        return db_obj


class DbYamlCreator(object):
    """
    Write database specific config file (db_config.yaml)
    """

    def __init__(self, yaml_out_path, required_db_yamls=None):
        """
        Checks presence of all required files. Offers method to write out the db specific config yaml.
        :param yaml_out_path:
        :param required_db_yamls:
        """
        self.db_obj_creator = DbObjBuilder.build_with_existing_requirements(
            yaml_out_path, required_db_yamls)
        self.db_obj = self.db_obj_creator.create()

    def write_cfg_file(self):
        self.db_obj.write_yaml()


def get_db_object_for_dbtype_and_version(db_type, db_version, db_base_dir=None, db_acc=None):
    if not db_base_dir:
        db_base_dir = os.getcwd()
    if not db_acc:
        db_acc = DbAccessor(db_base_dir)
    db_obj = db_acc.get_database_object(db_type, db_version)
    return db_obj


def print_summary_of_installed_dbs(db_base_dir):
    dbaccessor = DbAccessor(db_base_dir)
    dbaccessor.print_summary()


def is_database_schema_version_outdated(db_base_folder):
    yamls = [f for f in glob.glob(os.path.join(db_base_folder, "*/*/*.yaml"))]
    if any(is_db_yaml_outdated(f) for f in yamls):
        return True
    else:
        return False
