import logging
import collections

import os
import copy

import re
from pathlib import Path, PurePath, PureWindowsPath

from collections.abc import Sequence, Container

# from .file_management import FileManager, FileNameAnalyzer, serialize_to_file, open_in_explorer
from .configs import *
from .parser import *
from .file_management import *

# from .tools import *
# Naming conventions: https://swift.org/documentation/api-design-guidelines/#strive-for-fluent-usage

__all__ = [
    'BarelyDB',
    'BarelyDBConfig',
    'BarelyDBSystemConfig',
    'BarelyDBEntity',
    'FileManager',
    'FileNameAnalyzer',
    'serialize_to_file',
    'open_in_explorer',
    'ClassFileSerializer',
    'cattr_json_serialize',
]

# create logger
module_logger = logging.getLogger(__name__)
module_logger.setLevel(logging.DEBUG)


# general useful module components
def _reload_module():
    import sys
    import importlib

    current_module = sys.modules[__name__]
    module_logger.info('Reloading module %s' % __name__)
    importlib.reload(current_module)


class BarelyDB(object):
    config = None
    base_path = None

    auto_reload_components = None

    buid_type_paths = {}

    path_converter = Path
    long_windows_path_limit = None

    _MyBUIDParser = None
    _known_bases = None

    def __init__(
        self,
        name=None,
        base_path=None,
        path_depth=None,
        auto_reload_components=True,
        path_converter=Path,
        long_windows_path_limit=150,
    ):

        self.logger = module_logger

        self.long_windows_path_limit = long_windows_path_limit
        self.path_converter = path_converter

        # if base path is None, check default base path for existance and
        # break at first existing path
        if base_path is None:
            sys_config = BarelyDBSystemConfig.load(default_to_empty=True)
            default_base_path = sys_config.default_base_path.get(name, [])

            for def_base_path in default_base_path:
                if os.path.exists(def_base_path):
                    base_path = def_base_path
                    self.logger.info(f'Using default path {base_path}')
                    break

        if base_path is None:
            raise RuntimeError('Could not automatically determine base path of database!')

        self.config = BarelyDBConfig.load(base_path)

        if path_depth:
            self.logger.warning('Deprecated: path_depth in constructor is deprecated!')

        self.auto_reload_components = auto_reload_components

        self.base_path = Path(base_path)
        self.base_path = self.base_path.absolute().resolve()

        self.entity_paths = {}
        self.entity_properties = {}
        self.component_paths = {}

        self._MyBUIDParser = GenericBUIDParser.create_class(self.config.buid_types)

        self.buid_normalizer = self.BUIDParser(
            ignore_unknown=True, mode='unique', warn_empty=True, allow_components=False
        )

        self.buid_scan = self.BUIDParser(ignore_unknown=True, mode='first', warn_empty=False, allow_components=False)

        self.known_bases = self.config.known_bases
        self.register_error_handler(None)

    def register_error_handler(self, error_handler):
        self._error_handler = error_handler

    @property
    def error_handler(self):
        return self._error_handler

    @property
    def name(self):
        return self.config.name

    @property
    def path_depth(self):
        return self.config.path_depth

    @property
    def ignored_files(self):
        return self.config.ignored_files

    @property
    def known_bases(self):
        return self._known_bases

    @known_bases.setter
    def known_bases(self, value):
        # value = [str(estimate_pure_path(p)) for p in value]
        for p in value:
            if p[-1] not in ['/', '\\']:
                module_logger.error(f'Known base {p} does not end in / or \\ - consider fixing!')

        self._known_bases = value
        self.known_bases_re = [re.compile(re.escape(b), re.IGNORECASE) for b in self._known_bases]

    @property
    def buid_types(self):
        return self.config.buid_types

    @property
    def BUIDParser(self):
        return self._MyBUIDParser

    def __repr__(self):
        return f'{self.__class__.__qualname__}(name={self.name} @ {self.base_path} + {self.path_depth})'

    def __getitem__(self, buid):
        return self.get_entity(buid)

    def get_entity(self, buid):
        return BarelyDBEntity(buid, self)

    def __contains__(self, buid):
        buid = self.buid_normalizer(buid)
        return buid in self.entity_paths

    @property
    def entities(self):
        return list(self.entity_paths.keys())

    def resolved_file(self, filename):
        base_path_str = f'{str(self.base_path)}{os.sep}'
        base_path_str = base_path_str.replace('\\', '\\\\')
        # check if path structure of filename is Windows-like
        is_windows = False
        is_URL_like = False
        if '\\' in filename:
            is_windows = True
        elif '://' in filename:
            is_URL_like = True

        try:
            found_re = next((r for r in self.known_bases_re if r.match(filename)))
            filename_rel = found_re.sub('', filename)

            # This line works on the assumptions that relative posix paths
            # are always valid as relative windows paths
            filename_rel = PurePath(PureWindowsPath(filename_rel))

            filename_resolved = str(self.base_path / filename_rel)
        except StopIteration:
            filename_resolved = str(Path(filename))

        # To facilitate working on windows, we will extend windows paths when they
        # are long. see https://bugs.python.org/issue18199#msg260076
        filename_ext = extend_long_path_on_windows(filename_resolved, self.long_windows_path_limit)

        return filename_ext

    def relative_file(self, filename):
        filename = Path(extend_long_path_on_windows(str(self.resolved_file(filename)), 0))
        file_rel = Path(filename).relative_to(extend_long_path_on_windows(str(self.base_path), 0)).as_posix()
        return str(file_rel)

    def absolute_file(self, file_rel):
        file_abs = str(self.base_path.joinpath(file_rel).absolute().resolve())

        # this is for safety to make sure that the path is really relative to base_path
        try:
            file_rel_recover = self.relative_file(file_abs)
            file_abs_recover = str(self.base_path.joinpath(file_rel_recover).absolute().resolve())
        except ValueError:
            raise ValueError(f'Given relative file name is not result in a file in the given database! ({file_rel})')

        return str(file_abs_recover)

    @staticmethod
    def iter_subdir(path, depth=0):
        def _iter_subdir(path, depth=0):
            for sub in path.iterdir():
                if sub.is_dir():
                    if depth == 0:
                        yield sub
                    else:
                        for sub in _iter_subdir(sub, depth - 1):
                            yield sub

        return _iter_subdir(path, depth=depth)

    def load_entities(self, verbose=True):
        # candidates = [x.relative_to(self.base_path) for x in self.base_path.iterdir() if x.is_dir()]

        # candidates = [x for x in self.base_path.iterdir() if x.is_dir()]
        candidates = self.iter_subdir(self.base_path, depth=self.path_depth)
        buid_p = self.buid_scan

        candidates_buid = [(buid_p(c), c) for c in candidates]
        found_buids = [buid for buid, path in candidates_buid if buid is not None]
        self._duplicate_buid_count = collections.Counter(found_buids)
        self._duplicate_buid = [item for item, count in self._duplicate_buid_count.items() if item and count > 1]

        self.entity_paths = {buid: path for buid, path in candidates_buid if buid is not None}
        self.logger.info(f'Entities found: {len(self.entity_paths)}')

        self.check_for_duplicates(verbose=verbose)
        self.refresh_buid_type_paths(verbose=verbose)

    def check_for_duplicates(self, verbose=True):
        # Check for duplicates
        # found_buid = self.entity_paths.keys()
        duplicate_buid = self._duplicate_buid

        if duplicate_buid:
            self.logger.error(f'Following entities have multiple paths/folders: {duplicate_buid}')

        return bool(duplicate_buid)

    def refresh_buid_type_paths(self, verbose=True):
        # Scan all paths and determine target directories for each buid type!
        self.buid_type_paths = {}

        buid_types_done = set()
        buid_p = self.buid_scan

        for buid, entity_path in self.entity_paths.items():
            buid_type = buid_p.parse_type(buid)

            if buid_type in buid_types_done:
                pass
            else:
                if buid_type in self.buid_type_paths:
                    # if this type was already registered, check if parent path is the same
                    if self.buid_type_paths[buid_type] == entity_path.parent:
                        pass
                    else:
                        module_logger.warning(
                            f'Entity {buid} has a base path that does not match with other entities of the same type ({buid_type})!'
                        )
                        buid_types_done.add(buid_type)
                else:
                    # if this is the first entity of this type use the parent directory
                    self.buid_type_paths[buid_type] = entity_path.parent

        if verbose:
            for buid_type, buid_path in self.buid_type_paths.items():
                self.logger.info(f'{buid_type} --> {buid_path}')

    def load_components(self, buid):
        entity_path = self.get_entity_path(buid)
        base_buid = self.buid_normalizer(buid)

        candidates = self.iter_subdir(entity_path, depth=0)

        def component_parser(component_path):
            return self.buid_scan.parse_component(base_buid + component_path.name)

        candidates_components = [(component_parser(c), c) for c in candidates]
        component_paths = {component: path for component, path in candidates_components if component is not None}

        self.component_paths[base_buid] = component_paths

        # self.logger.debug(f'Components for {base_buid} found: {len(component_paths)}')

    def get_entity_path(self, buid):
        buid = self.buid_normalizer(buid)
        path = self.entity_paths[buid]
        path = path.absolute().resolve()
        return path

    @staticmethod
    def _extract_name_from_path(path, identifier):
        name_raw = Path(path).parts[-1]
        name_raw = name_raw.replace(identifier, '')
        if name_raw[0] in [' ', '_']:
            name_raw = name_raw[1:]

        return name_raw

    def get_entity_name(self, buid):
        path = self.get_entity_path(buid)
        return self._extract_name_from_path(path, buid)

    def get_component_paths(self, buid):
        buid = self.buid_normalizer(buid)
        if self.auto_reload_components or (buid not in self.component_paths):
            self.load_components(buid)

        paths = self.component_paths[buid]
        paths = {comp: Path(path).absolute().resolve() for comp, path in paths.items()}
        return paths

    def get_component_path(self, buid, component):
        component_paths = self.get_component_paths(buid)

        if component in component_paths:
            return component_paths[component]
        else:
            raise FileNotFoundError(f'No path for component {component} in {buid}!')

    def get_component_name(self, buid, component):
        path = self.get_component_path(buid, component)
        return self._extract_name_from_path(path, '-' + component)

    def create_new_entity(self, *, name, after=None, buid=None, reload=True):
        if not buid and not after:
            raise ValueError('create_new_entity needs either after or buid parameter!')

        if not buid:
            buid = self._get_free_buid(after)[0]

        self._create_entity_path(buid, name, reload=reload)

        return self[buid]

    def _get_free_buid(self, after, no_buids=1, no_free_biuds=None):
        buid_p = self.BUIDParser()

        btype, bid = buid_p.parse_type_and_uid(after)
        bid = int(bid)

        if no_free_biuds is None:
            no_free_biuds = no_buids

        found_buids = []

        while bid < 9999:
            new_buid = buid_p.format(btype, bid)
            bid += 1
            if new_buid in self.entities:
                found_buids = []
            else:
                found_buids += [new_buid]
                if len(found_buids) >= no_free_biuds:
                    break

        return found_buids[0:no_buids]

    def _create_entity_path(self, buid, name, reload=True):
        buid = self.buid_normalizer(buid)
        buid_type = self.buid_normalizer.parse_type(buid)

        try:
            buid_path = self.get_entity_path(buid)
            return buid_path
        except KeyError:
            # entity does not exist
            pass

        if buid_type not in self.buid_type_paths:
            raise ValueError(f'Do not know where to put entities of type {buid_type}!')

        # create new path
        buid_base_path = Path(self.buid_type_paths[buid_type])
        buid_path = buid_base_path.joinpath(f'{buid}_{name}')

        buid_path = buid_path.absolute().resolve()

        buid_path.mkdir(parents=False, exist_ok=True)

        self.entity_paths[buid] = Path(buid_path)

        if reload:
            self.load_entities()

        return buid_path

    def _get_files(self, buid, path, glob, must_contain_buid=False, output_as_str=True):
        files = Path(path).glob(glob)

        if must_contain_buid:
            buid_p = self.BUIDParser(ignore_unknown=False, mode='first', warn_empty=False)
            files_sel = [fn for fn in files if (buid_p(fn) == buid)]
            files = files_sel

        def ignore_file(fn):
            return fn in self.ignored_files

        files = [fn for fn in files if not ignore_file(fn.name)]

        if output_as_str:
            files = [str(fn) for fn in files]

        return list(files)


class BarelyDBEntity(object):

    path_converter = None

    @classmethod
    def like(cls, entity):
        """ Copy constructor for subclasses """
        if entity is None:
            return None

        return cls(buid=entity.buid_with_component, parent_bdb=entity.bdb)

    def __init__(self, buid, parent_bdb, component=None):
        self.logger = parent_bdb.logger
        self._bdb = parent_bdb
        self.path_converter = parent_bdb.path_converter

        buid_p = self.bdb.BUIDParser(ignore_unknown=False, mode='first', warn_empty=True, allow_components=False)
        self._buid = buid_p(buid)

        buid_ent_p = self.bdb.BUIDParser(ignore_unknown=False, mode='first', warn_empty=True, allow_components=False)
        self._buid_entity = buid_ent_p(buid)

        if component is None:
            buid_comp_p = self.bdb.BUIDParser(
                ignore_unknown=False, mode='first', warn_empty=False, allow_components=False
            )
            component = buid_comp_p.parse_component(buid)

        self.component = component

    def __repr__(self):
        try:
            name = f"'{self.name}'"

            components = self.components
            if len(components) > 6:
                comp = f', {len(components)} components'
            else:
                comp = f', components={components}' if components else ''

        except KeyError:
            name = '<does not exist!>'
            comp = ''

        return f"{self.__class__.__qualname__}('{self.buid_with_component}', {name}{comp})"

    def __eq__(self, other):
        return self.buid_with_component == other.buid_with_component

    @property
    def buid(self):
        return self._buid

    @property
    def buid_entity(self):
        return self._buid_entity

    @property
    def buid_with_component(self):
        return self._buid + (f'-{self.component}' if self.component is not None else '')

    @property
    def bdb(self):
        return self._bdb

    @property
    def parent(self):
        return BarelyDBEntity(self.buid_entity, self.bdb)

    @property
    def name(self):
        return self.bdb.get_entity_name(self.buid)

    @property
    def entity_path(self):
        return self.bdb.get_entity_path(self.buid_entity)

    @property
    def component_path(self):
        return self.bdb.get_component_path(self.buid_entity, self.component)

    @property
    def component_name(self):
        return self.bdb.get_component_name(self.buid, self.component)

    @property
    def component_paths(self):
        return self.bdb.get_component_paths(self.buid_entity)

    @property
    def components(self):
        return list(self.component_paths.keys())

    def __contains__(self, component):
        return component in self.components

    def get_component_entity(self, component):
        return self.__class__(buid=self.buid, parent_bdb=self.bdb, component=component)

    def __getitem__(self, component):
        return self.get_component_entity(component)

    @property
    def entity_exists(self):
        return self.buid_entity in self.bdb

    @property
    def exists(self):
        return self.entity_exists and (self.component is None or self.component in self.components)

    @property
    def path(self):
        return self.entity_path if self.component is None else self.component_path

    def open_in_explorer(self):
        open_in_explorer(self.path)

    def files(self, glob, must_contain_buid=False, output_as_str=True):
        if self.component is None:
            return self.entity_files(glob, must_contain_buid=must_contain_buid, output_as_str=output_as_str)
        else:
            return self.component_files(glob, must_contain_buid=must_contain_buid, output_as_str=output_as_str)

    def entity_files(self, glob, must_contain_buid=False, output_as_str=True):
        return self.bdb._get_files(
            self.buid_entity, self.entity_path, glob, must_contain_buid=must_contain_buid, output_as_str=output_as_str,
        )

    def component_files(self, glob, must_contain_buid=False, output_as_str=True):
        return self.bdb._get_files(
            self.buid_with_component,
            self.component_path,
            glob,
            must_contain_buid=must_contain_buid,
            output_as_str=output_as_str,
        )

    def resolve_relative_path(self, path):
        base_bath = Path(self.path)

        if type(path) in set([type(''), type(Path())]):
            path_resolved = str(base_bath.joinpath(path).absolute().resolve())
        elif isinstance(path, Sequence):
            return [self.resolve_relative_path(p) for p in path]
        else:
            raise TypeError('path needs to be str, Path, or Sequence (list etc.)!')

        return path_resolved

    def make_file_manager(
        self,
        raw_path='./',
        export_path='./',
        export_prefix='',
        secondary_data_paths=[],
        auto_string=True,
        auto_remove_duplicates=True,
        **kwds,
    ):

        rebase_path = lambda p: self.resolve_relative_path(p)

        raw_path = rebase_path(raw_path)
        export_path = rebase_path(export_path)
        secondary_data_paths = [rebase_path(p) for p in secondary_data_paths]

        options = kwds.copy()
        options.update(
            dict(
                raw_path=raw_path,
                export_path=export_path,
                export_prefix=export_prefix,
                secondary_data_paths=secondary_data_paths,
                auto_string=auto_string,
                auto_remove_duplicates=auto_remove_duplicates,
                long_windows_path_limit=self.bdb.long_windows_path_limit,
            )
        )

        return FileManager(**options)

    def create_component(self, *, component, name):
        # if component is None:
        #     raise ValueError(f'Cannot create component path if no component is specified ({self.buid_with_component})!')

        component = self.bdb.buid_normalizer.validated_component(component)

        existing_path = self.component_paths.get(component, None)
        if existing_path:
            self.logger.warning(f'Component {component} already exists! {str(existing_path)}')
            return existing_path

        base_bath = self.bdb.get_entity_path(self.buid_entity)
        path_name = f'-{component}'
        if name is not None:
            path_name += f'_{str(name)}'

        component_path = base_bath.joinpath(path_name)
        component_path.mkdir(parents=False, exist_ok=True)

        self.bdb.load_components(self.buid)

        return component_path

    def has_object(self, object_class, allow_parent=None):
        filename = object_class.file_serializer.resolve_file_from_entity(self, allow_parent=allow_parent)
        return False if filename is None else Path(filename).exists()

    def save_object(self, obj, **kwds):
        filename = obj.save_to_entity(self, **kwds)
        return filename

    def load_object(self, object_class, default=None, fail_to_exception=False, quiet=False):
        obj = None

        try:
            obj = object_class.load_from_entity(self)
        except (FileNotFoundError, KeyError):
            error_msg = f'No {object_class.__qualname__} object found for {self.buid_with_component}!'
            if not quiet:
                self.logger.warning(error_msg)
            if fail_to_exception:
                raise FileNotFoundError(error_msg)

        if obj is None and default is not None:
            if not quiet:
                self.logger.warning(f'Using default object!')
            obj = copy.copy(default)

        return obj
