from typing import Optional

from .code_desc import ConfToolsGlobal
from .code_specs import check_valid_code_spec, instantiate_spec
from .exceptions import (
    ConfToolsException,
    SemanticMistake,
    SemanticMistakeKeyNotFound,
    SyntaxMistake,
)
from .load_entries import load_entries_from_dir
from .patterns import is_pattern, pattern_matches, recursive_subst
from .special_subst import substitute_special
from .utils import (
    can_be_pickled,
    expand_environment,
    expand_string,
    friendly_path,
    indent,
    termcolor_colored,
)
from conf_tools import ID_FIELD, logger

# from contracts import contract, describe_type, describe_value
from pprint import pformat
import os
import traceback


__all__ = [
    "ObjectSpec",
]


class ObjectSpec(dict):
    """
        This is the class that knows how to instance entries.

        Users access it through ConfigMaster.

    """

    def __init__(self, name, pattern, check, instance_method, object_check, master):
        """
            Initializes the structure.

            :param:name: Decorative name for these objects (e.g. "vehicles")
            :param:pattern: Pattern for filenames ("*.vehicles.yaml")
            :param:check: Function to check the validity of the entries.
            :param:object_check: Function to check the validity of the values.
            :param:master: References to a Master instance.
        """
        dict.__init__(self)

        self.name = name
        self.pattern = pattern
        self.user_check = check
        self.instance_method = instance_method
        self.object_check = object_check

        self.master = master

        # List of files already read
        self.files_read = set()
        self.dirs_read = []
        self.dirs_to_read = []

        # ID -> file where it was found
        self.entry2file = {}

        self.templates = {}

        if not can_be_pickled(check):
            msg = 'Function %s passed as "check" cannot be pickled. ' % (check)
            msg += "This might create problems later but it is OK to continue."
            msg += " Happened for %s/%s" % (master, self)
            # TODO: add where (2 levels up)
            logger.warning(msg)

        if not can_be_pickled(instance_method):
            msg = (
                'Function %s passed as "instance_method" cannot be pickled. '
                % instance_method
            )
            msg += "This might create problems later but it is OK to continue."
            msg += " Happened for %s/%s" % (master, self)
            # TODO: add where (2 levels up)
            logger.warning(msg)

    def __repr__(self):
        return "ObjectSpec(%s;fread:%s;dread:%s;dtoread:%s)" % (
            self.name,
            self.files_read,
            self.dirs_read,
            self.dirs_to_read,
        )

    def make_sure_everything_read(self):
        """ Reads the rest of the directories that we need to read. """
        while self.dirs_to_read:
            d = self.dirs_to_read.pop(0)
            self._actually_load(d)

    def load_config_from_directory(self, directory):
        """
            Puts the directory in the list of directories to read.
            It will be read only later, when triggered by make_sure_everything_read().
        """
        self.dirs_to_read.append(directory)

    def __iter__(self):
        self.make_sure_everything_read()
        return dict.__iter__(self)

    def keys(self):
        self.make_sure_everything_read()
        return dict.keys(self)

    # @contract(key='str')
    def __contains__(self, key: str):
        self.make_sure_everything_read()
        return dict.__contains__(self, key) or self.matches_any_pattern(key)

    # @contract(key='str')
    def __getitem__(self, key: str):
        self.make_sure_everything_read()
        # Check if it is available literally:
        if dict.__contains__(self, key):
            # Note: we copy
            return dict.__getitem__(self, key).copy()
        else:
            pattern = self.matches_any_pattern(key)
            if pattern is None:
                raise SemanticMistakeKeyNotFound(key, self)

            spec_template = self.templates[pattern]
            matches = pattern_matches(pattern, key)
            try:
                x = recursive_subst(spec_template, **matches)
                # We didn't do it before...
                dirname = os.path.dirname(self.entry2file[pattern])
                x = substitute_special(x, dirname=dirname)
                return x
            except (SyntaxMistake, SemanticMistake) as e:
                prefix = "    | "
                msg = (
                    "%s\nError obtained while instantiating %r.\n"
                    "Pattern:\n%s"
                    "\nMatches:\n%s"
                    % (
                        e,
                        pattern,
                        indent(pformat(spec_template), prefix),
                        indent(pformat(matches), prefix),
                    )
                )
                raise ConfToolsException(msg)

    # @contract(key='str', returns='None|str')
    def matches_any_pattern(self, key: str) -> Optional[str]:
        self.make_sure_everything_read()

        possibilities = []
        # Look for the template that can match the longer
        for template in self.templates:
            matches = pattern_matches(template, key)
            if matches:
                score = -sum(len(x) for x in matches.values())
                possibilities.append((score, template, matches))

        # no matches
        if not possibilities:
            return None

        # only one match
        if len(possibilities) == 1:
            return possibilities[0][1]

        # sort by score
        possibilities.sort(key=lambda p: (-p[0]))

        # Check if there is a tie
        best = possibilities[0][0]
        ties = [p[1] for p in possibilities if p[0] == best]

        if len(ties) >= 2:
            msg = "Detected a tie. Key %r matches with same score: %r" % (key, ties)
            raise ValueError(msg)

        #        if len(possibilities) >= 2:
        #            for score, template, _ in possibilities:
        #                print(' score: %s template %s' % (score, template))
        #
        #            print('best: %r' % possibilities[0][1])
        #
        return possibilities[0][1]

    #        if not patterns:
    #            return None
    #        if len(patterns) > 1:
    #            msg = ('Warning, the key %r is ambiguous because it matches '
    #                   'more than one pattern; specifically, it matches the '
    #                   'patterns %s. Aborting because this might have *very* '
    #                   'unpredictable results.' % (key, ', '.join(patterns)))
    #            raise ValueError(msg)
    #        return patterns[0]

    def check(self, spec):
        """
            Checks that a spec is well formed, raises a ValueError if not.
            In addition to the user-defined check, it checks that there is
            a "id" field.
        """
        if not isinstance(spec, dict):
            msg = f"I expect the spec to be a dict, not {spec}"

            raise ValueError(msg)

        if not ID_FIELD in spec:
            msg = 'I expect the spec to contain a field "%r; found %s' % (
                ID_FIELD,
                (spec),
            )
            raise ValueError(msg)

        # This actually instances the classes at read config time.
        # if self.user_check is not None:
        #     self.user_check(spec)

    # @contract(id_object='str')
    def instance(self, id_object: str):
        """ Instances the entry with the given ID. """
        if not isinstance(id_object, str):
            raise ValueError("Expected string; got %r" % id_object)
        self.make_sure_everything_read()

        spec = self[id_object]
        try:
            value = self.instance_spec(spec)
        except Exception as e:
            msg = "Could not instance the object %r\n" % id_object
            if id_object in self.entry2file:
                msg += "defined at %s\n" % self.entry2file[id_object]
            else:
                msg += "(origin unknown)\n"
            msg += "because of this error:\n"
            if isinstance(e, ConfToolsException):
                st = str(e)
            else:
                st = traceback.format_exc()
            msg += indent(st.strip(), "| ")

            # from conf_tools.master import GlobalConfig

            # msg += '\n the conf is %s' % self

            raise ConfToolsException(msg)

        if self.user_check is not None:
            self.user_check(spec)
        return value

    # @contract(spec='dict')
    def instance_spec(self, spec):
        """ Instances the given spec using the "instance_method" function. """
        self.make_sure_everything_read()

        if self.instance_method is None:
            msg = "No instance method specified for %s." % self.name
            raise ValueError(msg)
        return self.instance_method(spec)

    # @contract(id_or_spec='str|dict', returns='tuple(str|None,*)')
    def instance_smart(self, id_or_spec):
        """
            Flexible instantiation method. The parameter can be either a
            string or a dictionary. If it is a string, it must be the ID.
            If it is a dictionary, it must be a valid spec. The method
            returns a tuple (id, object).

            Example usage: ..

                id_dog, dog = config.specs['dogs'].instance_smart('puppy')

                spec = dict(id='puppy', class='Puppy')
                id_dog, dog = config.specs['dogs'].instance_smart(spec)

        """
        self.make_sure_everything_read()

        if isinstance(id_or_spec, str):
            return id_or_spec, self.instance(id_or_spec)
        elif isinstance(id_or_spec, dict):
            spec = id_or_spec
            self.check(spec)
            id_spec = spec[ID_FIELD]
            return id_spec, self.instance_spec(spec)
        else:
            msg = f"Expected string or dict, found {id_or_spec}."
            raise ValueError(msg)

    # @contract(id_or_spec_or_code='str|dict|code_spec|*', returns='tuple(str|None,*)')
    def instance_smarter(self, id_or_spec_or_code):
        """
            Most flexible instantiation method. The parameter can be:
            - a string => id of spec
            - a spec (dict with fields: id, desc, and code) => it gets instantiated
            - a code spec (list of string, dict) => it gets called
              In this case, the id returned is None.
            - an object, already instantiated. It is passed then through
                the check function.

        """
        self.make_sure_everything_read()

        if isinstance(id_or_spec_or_code, (str, dict)):
            id_or_spec = id_or_spec_or_code
            return self.instance_smart(id_or_spec)
        elif isinstance(id_or_spec_or_code, list):
            code_spec = id_or_spec_or_code
            check_valid_code_spec(code_spec)
            ob = instantiate_spec(code_spec)
            return None, ob
        else:
            x = id_or_spec_or_code
            if self.object_check is None:
                msg = "No check specified; cannot decide if valid: %s" % (x)
                raise ValueError(msg)
            else:
                try:
                    self.object_check(x)
                except ValueError as e:
                    msg = "Object %s not valid:\n%s" % (x, e)
                    raise ValueError(msg)
            return None, x

    def force_load(self, directory):
        """ Will force reloading of directory. """
        if directory in self.dirs_read:
            self.dirs_read.remove(directory)

        self.load_config_from_directory(directory)

    def _actually_load(self, directory):
        """
            Loads all files in the directory, recursively, using
            the pattern specified in the constructor.
            Returns the number of new entries found.
        """
        if directory in self.dirs_read:
            # print('skipping directory %r because already read' % directory)
            return
        self.dirs_read.append(directory)

        from .global_config import dir_from_package_name, looks_like_package_name

        if looks_like_package_name(directory):
            # print('%r looks like a package' % directory)
            directory = dir_from_package_name(directory)
        else:
            # print('%r is plain looking' % directory)
            pass

        # print('actually loading directory %r for %s' % (directory, self.pattern))
        directory = expand_environment(directory)

        if not os.path.exists(directory):
            msg = "Directory %r does not exist." % directory
            raise SemanticMistake(msg)

        nfound = 0
        files = set()

        for where, x in load_entries_from_dir(directory, self.pattern):
            # logger.debug('loading %s:%s %s' % (where[0], where[1], x))
            filename = where[0]
            if filename in self.files_read:
                continue
            files.add(filename)

            name = x[ID_FIELD]

            if name in self.entry2file:
                old_filename = self.entry2file[name]
                msg = "Entry %r in\n  %s\n already found in\n  %s." % (
                    name,
                    friendly_path(filename),
                    friendly_path(old_filename),
                )

                if dict.__contains__(self, name):
                    old_entry = dict.__getitem__(self, name)
                else:
                    assert name in self.templates
                    old_entry = self.templates[name]

                same_entry = old_entry == x
                if not (same_entry):
                    msg += "\nThey are different. The new one:"
                    msg += "\n" + indent(pformat(x), "new |")
                    msg += "\nThe old one:"
                    msg += "\n" + indent(pformat(old_entry), "old |")
                    raise SemanticMistake(msg)
                else:
                    msg += "\n(Ignoring because same)"
                    logger.warn(msg)

            if is_pattern(name):
                self.templates[name] = x
            else:
                DESC_FIELD = "desc"
                # If it only contains the "id" field, then we try
                # to instantiate it
                only_id = len(x.keys()) == 1
                only_id_and_desc = set(x.keys()) == set([ID_FIELD, DESC_FIELD])

                if only_id or only_id_and_desc:
                    if not self.matches_any_pattern(name):
                        msg = (
                            "While trying to instantiate empty entry %r "
                            "in %s, I could not find any pattern matching."
                            % (name, friendly_path(filename))
                        )
                        msg += "\nPatterns: %s" % self.templates
                        raise SemanticMistake(msg)

                    try:
                        x2 = self.__getitem__(name)
                    except ConfToolsException:
                        raise
                    assert x[ID_FIELD] == name

                    if only_id_and_desc:
                        x2[DESC_FIELD] = x[DESC_FIELD]
                    x = x2

                try:
                    self.check(x)
                except Exception as e:
                    msg = "Error while checking the entry %r: %s" % (name, e)
                    msg += "\nEntry:\n" + indent(pformat(x), "  ")
                    msg += "\nException:\n"
                    msg += indent(traceback.format_exc(), "> ")
                    raise SemanticMistake(msg)

                dict.__setitem__(self, name, x)

            self.entry2file[name] = filename

            nfound += 1

        self.files_read.update(files)

        return nfound

    def summary_string_id_desc(self):
        """ Assuming that the entries are dictionaries
            with fields 'id' and 'desc', returns a summary string.
        """
        self.make_sure_everything_read()

        s = ""
        keys = sorted(self.keys())
        if not keys:
            return " (0 objects found)\n"
        maxlen = max([len(y) for y in keys])
        for x in keys:
            spec = self[x]
            if isinstance(spec, dict) and ("id" in spec) and ("desc" in spec):
                desc = spec["desc"].strip().replace("\n", " ")
                s += "- %s: %s\n" % (x.rjust(maxlen), desc)
        return s

    def summary_string_id_desc_patterns(self):
        """ Assuming that the entries are dictionaries
            with fields 'id' and 'desc', returns a summary string.
        """
        self.make_sure_everything_read()

        s = self.summary_string_id_desc()
        maxlen = max([len(y) for y in self.pattern])
        for x, spec in self.templates.items():
            if isinstance(spec, dict) and ("id" in spec) and ("desc" in spec):
                desc = spec["desc"].strip().replace("\n", " ")
                s += "- %s: %s\n" % (x.rjust(maxlen), desc)
        return s

    def _formatted_list_of_directories(self):
        def dirs():
            if not self.dirs_read:
                return "\nNo dirs read."
            s = "\nDirs read:"
            for d in self.dirs_read:
                s += "\n- %s" % friendly_path(d)
            return s

        def dirs2():
            if not self.dirs_to_read:
                return "\nNo dirs to read."
            s = "\nDirs to read:"
            for d in self.dirs_to_read:
                s += "\n- %s" % friendly_path(d)
            return s

        def files():
            if not self.files_read:
                return "\nNo files read (pattern: %s)." % self.pattern
            s = "\nFiles read:"
            for d in self.files_read:
                s += "\n- %s" % friendly_path(d)
            return s

        x = dirs() + files() + dirs2()

        # from conf_tools.master import GlobalConfig
        # x += '\n GlobalConfig dirs: %s' % GlobalConfig._dirs

        return x

    # @contract(names='str|list(str)', returns='list(str)')
    def expand_names(self, names):
        """
            The most flexible expansion routine on the planet.

            Examples: ..

                config.widgets.expand_names('*')
                config.widgets.expand_names('a,b*')
                config.widgets.expand_names(['a','b*'])

        """
        self.make_sure_everything_read()

        if len(self) == 0 and len(self.templates) == 0:
            msg = "No %s defined, cannot match names %s." % (self.name, names)
            msg += self._formatted_list_of_directories()
            raise SemanticMistake(msg)

        if isinstance(names, str):
            names = [names]
        assert isinstance(names, list)

        try:
            options = self.keys()
            expanded = expand_string(names, options)
        except ValueError:
            expanded = []

        if not expanded:
            msg = "Specified set %r of %s not found." % (names, self.name)
            msg += " Available %s: %s" % (self.name, options)
            raise SemanticMistake(msg)

        return expanded

    # @contract(id_spec='str', desc='str', code='code_spec')
    def add_spec(self, id_spec, desc, code):
        """ Adds manually one spec. """
        self.make_sure_everything_read()

        spec = dict(id=id_spec, desc=desc, code=code)
        assert not id_spec in self
        # TODO: check doesn't exist
        self[spec["id"]] = spec

    def print_summary(self, stream, instance=False, raise_instance_error=False):
        self.make_sure_everything_read()

        ConfToolsGlobal.log_instance_error = (
            False  # XXX: find more elegant way # XXX: preserve
        )

        stream.write("Spec %s:\n" % self.name)

        if self.files_read:
            stream.write("* Found %d config files:\n" % len(self.files_read))
            for filename in self.files_read:
                stream.write("  - %s\n" % filename)
        else:
            stream.write("* No config file found.\n")

        # if any entry defined...
        if len(self):
            stream.write("* Found %d objects: \n" % len(self))
            #         ordered = [(id_spec, self.specs[id_spec]) for id_spec in sorted(self.specs.keys())]
            #
            for id_spec in self:
                desc = dict.__getitem__(id_spec).get("desc", "")

                # TODO: use first sentence
                # Get first line
                desc = desc.strip().split("\n")[0]

                if desc == "":
                    desc = termcolor_colored("(no desc available)", color="grey")
                stream.write("  %40s:  %s\n" % (id_spec, desc))
                if instance:
                    try:
                        x = self.instance(id_spec)
                        xs = str(x)
                        xs = termcolor_colored(indent(xs, "    "), color="blue")
                    except Exception as e:
                        if raise_instance_error:
                            raise
                        else:
                            xs = "Could not instance object %r:\n" % id_spec
                            xs += indent(str(e), "| ")
                            xs = termcolor_colored(indent(xs, "    "), color="red")

                    stream.write(xs + "\n")
                #                     stream.write(' desc.
                # instance
        else:
            stream.write("* No objects found.\n")

        if self.templates:
            stream.write("* Found %d templates: \n" % len(self.templates))
            for id_template in self.templates:
                stream.write("  - %s\n" % id_template)
        else:
            stream.write("* No templates found.\n")
