import math
import cmath
from typing import Dict, Tuple, Callable, Iterable, List, DefaultDict, OrderedDict
from decimal import Decimal
from collections import defaultdict, Counter
from logging import getLogger

from yadd import application_name

log = getLogger(application_name)


class MiscompareException(Exception):
    ...


def are_comparable(a, b) -> bool:
    """
    determine if two object are comparable
    :param a: object a
    :param b: object b
    :return: True if the objects are comparable
    """
    comparable = False
    if type(a) == type(b):
        comparable = True
    elif isinstance(a, (dict, DefaultDict, OrderedDict)) and isinstance(b, (dict, DefaultDict, OrderedDict)):
        comparable = True
    elif isinstance(a, (float, int, Decimal, complex)) and isinstance(b, (float, int, Decimal, complex)):
        # various "numbers" are comparable between each other
        comparable = True
    return comparable


class Yadd:
    """
    Yet Another Dict Difference Alternative
    """

    def __init__(
        self,
        expected: Dict | Iterable | float | int | str | Decimal | complex | bool,
        under_test: Dict | Iterable | float | int | str | Decimal | complex | bool,
        description: str = None,
        rel_tol: float = 1e-9,
        abs_tol: float = 0.0,
        miscompare_callback: Callable = log.error,
        expect_dict_key_ordered: bool = False,
        raise_exception_on_miscompare: bool = True,
        numeric_miscompares: DefaultDict[float, List] = None,
    ):

        """
        Yet Another Dict Difference Alternative

        :param expected: expected results
        :param under_test: unit under test
        :param description: description of test
        :param rel_tol: for floats, relative error tolerance (default is the same as math.isclose() rel_tol)
        :param abs_tol: for floats, absolute error tolerance (default is the same as math.isclose() abs_tol)
        :param miscompare_callback: function to call on a miscompare (can be a logger such as log.error or print to output to stdout)
        :param expect_dict_key_ordered: set to True to check if input dicts keys have the same order
        :param raise_exception_on_miscompare: True to raise exception on a miscompare
        :param numeric_miscompares: optional dict that holds all the numeric miscompares (key is the difference value). Useful to get an overall rollup across multiple Yadd instances.
        """

        # function takes description as a tuple
        if description is None:
            _description_tuple = tuple()  # type: Tuple
        else:
            _description_tuple = (description,)

        self._rel_tol = rel_tol
        self._abs_tol = abs_tol
        self._miscompare_callback = miscompare_callback
        self._expect_dict_key_ordered = expect_dict_key_ordered
        self._raise_exception_on_miscompare = raise_exception_on_miscompare

        self._compare_results = []  # type: List[bool]

        if numeric_miscompares is None:
            self._numeric_miscompares = defaultdict(list)  # type: DefaultDict[float, List]
        else:
            self._numeric_miscompares = numeric_miscompares

        self._yadd(expected, under_test, _description_tuple)

    def match(self) -> bool:
        """
        Return True if expected matches under_test
        :return: True on match
        """
        return all(self._compare_results)

    def compare_results(self) -> List[bool]:
        """
        Get the compare results as a list of booleans
        :return: compare results list
        """
        return self._compare_results

    def get_numeric_miscompares(self) -> Dict[float, List]:
        """
        Get a sorted dict of lists with the key being the difference. Useful for debugging/displaying the largest differences first.
        :return: sorted dict of miscompares
        """
        return {k: self._numeric_miscompares[k] for k in sorted(self._numeric_miscompares.keys(), reverse=True)}

    def pass_rate(self) -> float:
        pass_counter = Counter(self._compare_results)
        pass_rate = float(pass_counter[True]) / float(pass_counter.total())
        return pass_rate

    def _yadd(
        self, expected: Dict | Iterable | float | int | str | Decimal | complex | bool, under_test: Dict | Iterable | float | int | str | Decimal | complex | bool, description: Tuple[str, ...]
    ):
        """
        "core" dict differ

        :param expected: expected results
        :param under_test: unit under test
        :param description: description Tuple

        """

        leaf_result = None

        if are_comparable(expected, under_test):

            if isinstance(expected, (dict, defaultdict)) and isinstance(under_test, (dict, defaultdict)):
                # dicts and defaultdicts
                if (self._expect_dict_key_ordered and list(expected.keys()) != list(under_test.keys())) or (
                    not self._expect_dict_key_ordered and set(expected.keys()) != set(under_test.keys())
                ):
                    # make sure both dicts keys are the same
                    self._miscompare_callback(f"keys {list(expected.keys())} != {list(under_test.keys())}")
                    self._compare_results.append(False)
                    leaf_result = False
                else:
                    for key, expected_value in expected.items():
                        uut_value = under_test[key]
                        t = description + (key,)
                        self._yadd(
                            expected_value,
                            uut_value,
                            t,
                        )
            elif isinstance(expected, (list, tuple)) and isinstance(under_test, (list, tuple)):
                # lists and tuples
                if len(expected) != len(under_test):
                    # make sure both lists/tuples are the same length
                    self._miscompare_callback(f"can not compare lists or tuple of different lengths : {len(expected)=},{len(under_test)=}")
                    self._compare_results.append(False)
                    leaf_result = False
                else:
                    for i, expected_value in enumerate(expected):
                        uut_value = under_test[i]
                        self._yadd(
                            expected_value,
                            uut_value,
                            description + (f"index={i}",),
                        )
            else:
                # "leaf" values
                if isinstance(expected, (float, int, Decimal, complex)) and isinstance(under_test, (float, int, Decimal, complex)):
                    # numbers
                    if isinstance(expected, Decimal):
                        expected = float(expected)
                    if isinstance(under_test, Decimal):
                        under_test = float(under_test)
                    is_close_result = cmath.isclose(expected, under_test, rel_tol=self._rel_tol, abs_tol=self._abs_tol)
                    self._compare_results.append(is_close_result)
                    if not is_close_result and self._numeric_miscompares is not None:
                        # handles both scalar and complex values
                        try:
                            difference = float(min(abs((expected - under_test) / expected), abs((expected - under_test) / under_test)))
                        except ZeroDivisionError:
                            difference = math.nan
                        self._numeric_miscompares[difference].append(f"{'.'.join(description)} : expected={expected=} != under_test={under_test=}")
                else:
                    # other things that are directly comparable, such as str and bool
                    try:
                        self._compare_results.append(expected == under_test)
                    except ValueError as e:
                        self._compare_results.append(False)
                        self._miscompare_callback(f"{type(expected)=},{type(under_test)=} not supported ({e})")
                leaf_result = self._compare_results[-1]
        else:
            self._miscompare_callback(f"{type(expected)=},{type(under_test)=} not supported")
            if self._raise_exception_on_miscompare:
                raise MiscompareException("not comparable", type(expected), type(under_test))

        if leaf_result is False:
            self._miscompare_callback(f"{'.'.join(description)} : expected={expected} != under_test={under_test}")
            if self._raise_exception_on_miscompare:
                raise MiscompareException(expected, under_test)


def yadd(
    expected: Dict | Iterable | float | int | str | Decimal | complex | bool,
    under_test: Dict | Iterable | float | int | str | Decimal | complex | bool,
    description: str = None,
    rel_tol: float = 1e-9,
    abs_tol: float = 0.0,
    miscompare_callback: Callable = print,
    expect_dict_key_ordered: bool = False,
    raise_exception_on_miscompare: bool = True,
) -> bool:
    """
    Yet Another Dict Difference Alternative

    :param expected: expected results
    :param under_test: unit under test
    :param description: description of test
    :param rel_tol: for floats, relative error tolerance (default is the same as math.isclose() rel_tol)
    :param abs_tol: for floats, absolute error tolerance (default is the same as math.isclose() abs_tol)
    :param miscompare_callback: function to call on a miscompare (can be a logger such as log.error or print to output to stdout)
    :param expect_dict_key_ordered: set to True to check if input dicts keys have the same order
    :param raise_exception_on_miscompare: True to raise exception on a miscompare
    :return True on a match (otherwise False)
    """

    _yadd = Yadd(expected, under_test, description, rel_tol, abs_tol, miscompare_callback, expect_dict_key_ordered, raise_exception_on_miscompare)
    return _yadd.match()
