from abc import ABC, abstractmethod
from util.representation import ReprMixin
from typing import Union, List
import pandas as pd


class Criterion(ABC):
    """
    Abstract Criterion base class.
    All criteria should implement the interface defined in this ABC.
    """
    @abstractmethod
    def __call__(self, value: Union[float, bool]) -> bool:
        """
        Parameters
        ----------
        value: float or bool
            The value to be evaluated.

        Returns
        -------
        passed: bool
            Returns true if the criterion was satisfied.
        """
        return False


class TestResult:
    """
    Result of the evaluation of a single :class:`criterion <connectome.function.modeltest.Criterion>`.

    Parameters
    ----------

    criterion_name: str
        Name of the checked criterion.

    criterion: Union[Criterion, None]
        The checked criterion.

    score: float
        The score of the criterion.
    """
    def __init__(self, criterion_name: str, criterion: Union[Criterion, None], score: Union[float, KeyError],
                 passed: bool):
        self.criterion_name = criterion_name
        self.criterion = criterion
        self.score = score
        self.passed = passed

    def __repr__(self):
        return "{cls}(criterion={criterion}, score={score}, passed={passed})".format(
                cls=self.__class__.__name__,
                criterion= self.criterion,
                score=self.score, passed=self.passed)

    def __bool__(self):
        return self.passed

    def __float__(self):
        return self.score

    def to_dict(self):
        return {self.criterion_name,  self.score}


class Task(ReprMixin, ABC):
    """
    Abstract base class for tasks.

    All tasks should derive from this class.
    """
    @abstractmethod
    def __call__(self, model) -> dict:
        """

        Parameters
        ----------
        model: Node
            A model instance of ER, EXP, SORN, LL, API, ERFEVER or SYN.
            Please note the following correspondences between the class names used in this
            code package and the model acronyms used in the manuscript (class name: acronym):

            * ER: ER-ESN,
            * EXP: EXP-LSM,
            * LL: LAYERED,
            * SYN: SYNFIRE,
            * ERFEVER: FEVER,
            * API: API,
            * SORN: STDP-SORN.

        Returns
        -------

        task_result: dict
            Dictionary of task results.
        """
        return {}


class TestSuiteResult:
    """
    Result of a single test.

    Parameters
    ----------

    model: Node
        The evaluated model.

    """
    def __init__(self, model: str):
        self.criteria = {}
        self.model = model

    def add(self, evaluation: TestResult):
        """
        Add a single test result of the evaluation of a single criterion to the test suite result.

        Parameters
        ----------
        evaluation: TestResult
            Result of a single test.
        """
        self.criteria[evaluation.criterion_name] = evaluation

    def __repr__(self):
        return "<{}, model={} {}>".format(self.__class__.__name__, self.model, self.criteria)

    @property
    def passed(self):
        """
        True if all criteria were passed.
        """
        return all(evaluation.passed for evaluation in self.criteria.values())

    def __bool__(self):
        return self.passed

    def __getitem__(self, item):
        if item == "model":
            return self.model
        return self.criteria[item]

    def items(self):
        return self.criteria.items()

    def to_dict(self, with_model_pars=False):
        dct = self.model_dict() if with_model_pars else {}

        for name, value in self.criteria.items():
            dct[name] = value.score
        return dct

    def model_dict(self):
        name, pars = self.model.split("(")
        pars = pars[:-1]
        pars = pars.split(", ")
        pars = [p.split("=") for p in pars]
        dct = dict(pars)
        dct["name"] = name

        def to_numeric(obj):
            for type_ in [int, float, str]:
                try:
                    return type_(obj)
                except ValueError:
                    pass
            return obj

        dct = {"model_" + key: to_numeric(value) for key, value in dct.items()}
        return dct


class TestSuiteResultList:
    """
    Represents a list of test suite results.

    Parameters
    ----------
    results: List[TestSuiteResult]
        A list of :class:`test suite results <connectome.function.modeltest.TestSuiteResult>`.

    """
    def __init__(self, results: List[TestSuiteResult]):
        self._results_list = results

    @property
    def results_list(self):
        """
        Transforms the possibly passed iterator into a list on first call and returns it.
        """
        self._try_convert_to_list()
        return self._results_list

    def _try_convert_to_list(self):
        if not isinstance(self._results_list, list):
            self._results_list = list(self._results_list)

    def done(self):
        """
        Check if the tests have executed already.
        This is meant to work with the Future protocol as in the concurrent.futures module.

        Returns
        -------
        done: bool
            True if done, false otherwise.
        """
        try:
            return self._results_list.done()
        except AttributeError:
            return True

    def wait(self):
        """
        Wait for all tasks to be executed and return then.
        """
        try:
            self._results_list.wait()
            self._try_convert_to_list()
        except AttributeError:
            return

    @property
    def passed(self):
        """
        True if all tests were passed.
        """
        return all(item.passed for item in self)

    def append(self, item: TestSuiteResult):
        """

        Parameters
        ----------
        item: TestSuiteResult
            Append item to the list.
        """
        self.results_list.append(item)

    def __iter__(self):
        return iter(self.results_list)

    def __getitem__(self, item):
        return self.results_list[item]

    def __repr__(self):
        return "<{} tests: \n    {}, passed={}>".format(self.__class__.__name__,
                                                  "\n    ".join(repr(res) for res in self.results_list), self.passed)

    def to_dataframe(self, with_model_pars=False):
        return pd.DataFrame([res.to_dict(with_model_pars=with_model_pars) for res in self.results_list])


class TestSuite:
    """
    Test suite for functional checking.

    Parameters
    ----------
    task: Task
        The task for this test suite.
    """
    mapper = map  #: The mapper which executes the tasks. Can be the built in map or a distributed version of it. Defaults to map

    def __init__(self, task: Task):

        self.criteria = {}
        self.task = task
        self._enqueued_models = []

    def __repr__(self):
        return "<{} task={task}, criteria={criteria}>".format(self.__class__.__name__, task=self.task, criteria=self.criteria)

    def add_criterion(self, criterion_name: str, criterion: Criterion):
        """
        Add a test criterion.

        Parameters
        ----------
        criterion_name: str
            Name of the quantity to be tested.
            This has to correspond to one of the output keys of the task.

        criterion: Criterion
            The test criteria.
        """
        self.criteria[criterion_name] = criterion

    def test_model(self, model) -> TestSuiteResult:
        """
        Test a single model.

        Parameters
        ----------

        model: Model
            Can be run by the model_runner.
            Usually an instance of one of the network models.
        """
        run_result = self.task(model)
        test_suite_result = TestSuiteResult(str(model))

        # Add results of test criteria/
        # If a criterion is demanded but not found
        # the test os not passed.
        for criterion_name, criterion in self.criteria.items():
            try:
                score = run_result[criterion_name]
            except KeyError as key_error:
                test_suite_result.add(TestResult(criterion_name, criterion, key_error, False))
            else:
                passed = criterion(score)
                test_suite_result.add(TestResult(criterion_name, criterion, score, passed))

        # Store addtional output scores
        # which are not in the criteria
        for output in set(run_result) - set(self.criteria):
            test_suite_result.add(TestResult(output, None, run_result[output], True))
        return test_suite_result

    def enqueue_model(self, model):
        """
        Enqueue a model to be tested, but do not execute the tests.

        Parameters
        ----------

        model: Model
            Can be run by the model_runner.
            Usually an instance of one of the network models.
        """
        self._enqueued_models.append(model)

    def execute_enqueued_models(self, mapper=None) -> TestSuiteResultList:
        """
        Execute the test task on all enqueued models.

        Parameters
        ----------
        mapper: map-like object
            Can be distributed. Has to work like a map.

        Returns
        -------

        test_suite_result_list: TestSuiteResultList
            List of results of the test.
        """
        mapper = mapper or self.mapper
        results = mapper(self.test_model, self._enqueued_models)
        test_suite_result_list = TestSuiteResultList(results)
        self.test_suite_result_list_ = test_suite_result_list
        return test_suite_result_list
