from __future__ import annotations

import logging
import re
from collections import OrderedDict

from nose2 import events, exceptions, util
from nose2.suite import LayerSuite

BRIGHT = r"\033[1m"
RESET = r"\033[0m"

__unittest = True

log = logging.getLogger(__name__)


class MissingParentLayer(Exception):
    pass


class Layers(events.Plugin):
    alwaysOn = True

    def startTestRun(self, event):
        event.suite = self.make_suite(event.suite, self.session.testLoader.suiteClass)

    def get_layers_from_suite(self, suite, suiteClass):
        top_layer = suiteClass()
        layers_dict = OrderedDict()
        for test in self.flatten_suite(suite):
            layer = getattr(test, "layer", None)
            if layer:
                if layer not in layers_dict:
                    layers_dict[layer] = LayerSuite(self.session, layer=layer)
                layers_dict[layer].addTest(test)
            else:
                top_layer.addTest(test)
        self.get_parent_layers(layers_dict)
        return top_layer, layers_dict

    def get_parent_layers(self, layers_dict):
        while True:
            missing_parents = []
            for layer in layers_dict.keys():
                for parent in layer.__bases__:
                    if parent is object:
                        continue
                    if parent not in layers_dict:
                        missing_parents.append(parent)
            if not missing_parents:
                break
            for parent in missing_parents:
                layers_dict[parent] = LayerSuite(self.session, layer=parent)

    def make_suite(self, suite, suiteClass):
        top_layer, layers_dict = self.get_layers_from_suite(suite, suiteClass)
        tree = {}
        unresolved_layers = self.update_layer_tree(tree, layers_dict.keys())
        while unresolved_layers:
            remaining = self.update_layer_tree(tree, unresolved_layers)
            if len(remaining) == len(unresolved_layers):
                raise exceptions.LoadTestsFailure(
                    "Could not resolve layer dependencies"
                )
            unresolved_layers = remaining
        for layer in tree.keys():
            if layer and layer not in layers_dict:
                layers_dict[layer] = LayerSuite(self.session, layer=layer)
        self.tree_to_suite(tree, None, top_layer, layers_dict)
        return top_layer

    @classmethod
    def update_layer_tree(cls, tree, layers):
        remaining = []
        for layer in layers:
            try:
                cls.add_layer_to_tree(tree, layer)
            except MissingParentLayer:
                remaining.append(layer)
        return remaining

    @classmethod
    def insert_mixins(cls, tree, layer, outer):
        mixins = getattr(layer, "mixins", None)
        if not mixins:
            return outer
        last = outer
        for mixin in mixins:
            mixin_ancestor = cls.get_oldest_parent(mixin)
            if last is None:
                tree.setdefault(None, []).append(mixin_ancestor)
            else:
                # The mixin_ancestor can be a layer that has been added to the
                # tree already. If so, it should a base layer, since it's the
                # last ancestor. We need to remove it from there, and insert it
                # in the "last" layer.
                if mixin_ancestor in tree[None]:
                    tree[None].remove(mixin_ancestor)
                tree[last].append(mixin_ancestor)
                if mixin_ancestor not in tree:
                    tree[mixin_ancestor] = []
            if mixin not in tree:
                tree[mixin] = []
            last = mixin
        return last

    @classmethod
    def insert_layer(cls, tree, layer, outer):
        if outer is object:
            outer = cls.insert_mixins(tree, layer, None)
        elif outer in tree:
            outer = cls.insert_mixins(tree, layer, outer)
        else:
            err = f"{outer} not found in {tree}"
            raise exceptions.LoadTestsFailure(err)
        if outer is None:
            tree.setdefault(None, []).append(layer)
        else:
            tree[outer].append(layer)
        tree[layer] = []

    @staticmethod
    def get_parents_from_tree(layer, tree):
        parents = []
        for key, value in tree.items():
            if layer in value:
                parents.append(key)

    @classmethod
    def get_oldest_parent(cls, layer):
        # FIXME: we assume that there is only one oldest parent
        # it should be the case most of the time but it will break sometimes.
        oldest = True
        for parent in layer.__bases__:
            if parent in [None, object]:
                continue
            else:
                oldest = False
                return cls.get_oldest_parent(parent)
        if oldest:
            return layer

    @classmethod
    def add_layer_to_tree(cls, tree, layer):
        parents = layer.__bases__
        if not parents:
            err = "Invalid layer {0}: should at least inherit from `object`"
            raise exceptions.LoadTestsFailure(err.format(layer))
        for parent in parents:
            if parent not in tree and parent is not object:
                raise MissingParentLayer
        # if we reached that point, then all the parents are in the tree
        # if there are multiple parents, we first try to get the closest
        # to the current layer.
        for parent in parents:
            if not cls.get_parents_from_tree(parent, tree):
                cls.insert_layer(tree, layer, parent)
                return
        raise exceptions.LoadTestsFailure(f"Failed to add {layer}")

    @classmethod
    def tree_to_suite(cls, tree, key, suite, layers):
        _suite = layers.get(key, None)
        if _suite:
            suite.addTest(_suite)
            suite = _suite
        sublayers = tree.get(key, [])
        # ensure that layers with a set order are in order
        sublayers.sort(key=cls.get_layer_position)
        for layer in sublayers:
            cls.tree_to_suite(tree, layer, suite, layers)

    @classmethod
    def flatten_suite(cls, suite):
        out = []
        for test in suite:
            try:
                out.extend(cls.flatten_suite(test))
            except TypeError:
                out.append(test)
        return out

    @staticmethod
    def get_layer_position(layer):
        pos = getattr(layer, "position", None)
        # ... lame
        if pos is not None:
            key = "%04d" % pos
        else:
            key = layer.__name__
        return key


class LayerReporter(events.Plugin):
    commandLineSwitch = (
        None,
        "layer-reporter",
        "Add layer information to test reports",
    )
    configSection = "layer-reporter"

    def __init__(self) -> None:
        self.indent = self.config.as_str("indent", "  ")
        self.colors = self.config.as_bool("colors", False)
        self.highlight_words = self.config.as_list(
            "highlight-words", ["A", "having", "should"]
        )
        self.highlight_re = re.compile(r"\b(%s)\b" % "|".join(self.highlight_words))
        self.layersReported: set[LayerSuite] = set()

    def reportStartTest(self, event):
        if self.session.verbosity < 2:
            return
        test = event.testEvent.test
        layer = getattr(test, "layer", None)
        if not layer:
            return
        for ix, lys in enumerate(util.ancestry(layer)):
            for layer in lys:
                if layer not in self.layersReported:
                    desc = self.describeLayer(layer)
                    event.stream.writeln(f"{self.indent * ix}{desc}")
                    self.layersReported.add(layer)
        event.stream.write(self.indent * (ix + 1))

    def describeLayer(self, layer):
        return self.format(getattr(layer, "description", layer.__name__))

    def format(self, st):
        if self.colors:
            return self.highlight_re.sub(rf"{BRIGHT}\1{RESET}", st)
        return st

    def describeTest(self, event):
        if hasattr(event.test, "methodDescription"):
            event.description = self.format(event.test.methodDescription())
        if event.errorList and hasattr(event.test, "layer"):
            # walk back layers to build full description
            self.describeLayers(event)
        # we need to remove "\n" from description to keep a well indented report when
        # tests have docstrings
        # see https://github.com/nose-devs/nose2/issues/327 for more information
        event.description = event.description.replace("\n", " ")

    def describeLayers(self, event):
        desc = [event.description]
        base = event.test.layer
        for layer in base.__mro__ + getattr(base, "mixins", ()):
            if layer is object:
                continue
            desc.append(self.describeLayer(layer))
        desc.reverse()
        event.description = " ".join(desc)


# for debugging
# def printtree(suite, indent=''):
#     import unittest
#     print('%s%s ->' % (indent, getattr(suite, 'layer', 'no layer')))
#     for test in suite:
#         if isinstance(test, unittest.BaseTestSuite):
#             printtree(test, indent + '  ')
#         else:
#             print('%s %s' % (indent, test))
#     print('%s<- %s' % (indent, getattr(suite, 'layer', 'no layer')))
