# coding=utf-8
from collections.abc import Callable
from typing import Any

TyArr = list[Any]
TyDic = dict[Any, Any]
TyAoA = list[TyArr]
TyAoD = list[TyDic]
TyTup = tuple[Any, ...]
TyAoT = list[TyTup]
TyBool = bool
TyCallable = Callable[..., Any]
TyDoA = dict[Any, TyArr]
TyDoD = dict[Any, TyDic]
TyDoAoD = dict[Any, TyAoD]
TyDoC = dict[str, TyCallable]
TyStr = str
TyToAoD = tuple[TyAoD, TyAoD]

TnAny = None | Any
TnArr = None | TyArr
TnAoA = None | TyAoA
TnCallable = None | TyCallable
TnDic = None | TyDic
TnAoD = None | TyAoD
TnStr = None | TyStr


class AoD:
    """Manage Array of Dictionaries.

    A static/class-method utility class for operations on lists of dicts.
    """

    @staticmethod
    def add(aod: TyAoD, obj: Any) -> None:
        """Add an object to the array of dictionaries.

        Appends a dict or extends with a list. Raises :exc:`Exception` otherwise.

        :param TyAoD aod: Target array of dictionaries.
        :param Any obj: Dictionary, list, or other (raises).
        :returns: None
        :raises Exception: If *obj* is neither dict nor list.
        """
        if isinstance(obj, dict):
            aod.append(obj)
        elif isinstance(obj, list):
            aod.extend(obj)
        else:
            _msg = f"The object {obj} is not a dictionary or list"
            raise TypeError(_msg)

    @classmethod
    def add_mapped_dic_value(
          cls, aod: TyAoD, dic: TnDic, key: TnAny, fnc: TyCallable) -> TyAoD:
        """Extend array with mapped dictionary value if key exists.

        Applies *fnc* to ``dic[key]`` and adds the result via :meth:`add`.

        :param TyAoD aod: Target array (or empty).
        :param TnDic dic: Source dictionary.
        :param TnAny key: Key to look up.
        :param TyCallable fnc: Mapping function applied to the value.
        :returns: The (possibly extended) array of dictionaries.
        :rtype: TyAoD
        """
        if not aod:
            aod_new: TyAoD = []
        else:
            aod_new = aod
        if not dic:
            return aod_new
        if not key:
            return aod_new
        if key not in dic:
            return aod_new
        _obj: Any = fnc(dic[key])
        cls.add(aod_new, _obj)
        return aod_new

    @staticmethod
    def append_unique(aod: TyAoD, dic: TyDic) -> None:
        """Append *dic* only if not already present in *aod*.

        :param TyAoD aod: Target array of dictionaries.
        :param TyDic dic: Dictionary to append.
        :returns: None
        """
        if aod is None:
            return
        if not dic:
            return
        if dic not in aod:
            aod.append(dic)

    @staticmethod
    def apply_function(aod: TyAoD, fnc: TnCallable, kwargs: TnDic) -> TyAoD:
        """Apply *fnc(dic, kwargs)* to every dictionary in the array.

        :param TyAoD aod: Source array of dictionaries.
        :param TnCallable fnc: Callable ``fnc(dic, kwargs) -> dic``.
        :param TnDic kwargs: Extra arguments passed to *fnc*.
        :returns: New array of transformed dictionaries.
        :rtype: TyAoD
        """
        if not aod or not fnc:
            return aod
        _aod_new: TyAoD = []
        for _dic in aod:
            _dic = fnc(_dic, kwargs)
            _aod_new.append(_dic)
        return _aod_new

    @classmethod
    def merge_aod(cls, aod0: TnAoD, aod1: TnAoD) -> TyAoD:
        """Merge two arrays of dictionaries (cross-product merge).

        Every dict in *aod0* is merged (``|``) with every dict in *aod1*.

        :param TnAoD aod0: First array.
        :param TnAoD aod1: Second array.
        :returns: Cross-product merged array.
        :rtype: TyAoD
        """
        if not aod0:
            if not aod1:
                return []
            return aod1
        if not aod1:
            if not aod0:
                return []
            return aod0
        _aod_new: TyAoD = []
        for _dic0 in aod0:
            if _dic0 is None:
                continue
            for _dic1 in aod1:
                _aod_new.append(_dic0 | _dic1)
        return _aod_new

    @classmethod
    def merge_dic(cls, aod: TnAoD, dic: TnDic) -> TnAoD:
        """Merge every dictionary in *aod* with a single *dic*.

        :param TnAoD aod: Array of dictionaries.
        :param TnDic dic: Dictionary to merge into each element.
        :returns: New merged array, or *aod* if *dic* is ``None``.
        :rtype: TnAoD
        """
        if dic is None:
            return aod
        _aod_new: TyAoD = cls.merge_aod(aod, [dic])
        return _aod_new

    @staticmethod
    def nvl(aod: TnAoD) -> TyArr | TyAoD:
        """Return *aod* or ``[]`` if falsy (SQL-style NVL).

        :param TnAoD aod: Array of dictionaries or ``None``.
        :returns: *aod* or empty list.
        :rtype: TyArr | TyAoD
        """
        if not aod:
            _aod_new = []
        else:
            _aod_new = aod
        return _aod_new

    @staticmethod
    def sh_doaod_split_by_value_is_not_empty(
            aod: TyAoD, key: Any, key_n: Any, key_y: Any) -> TyDoAoD:
        """Split into a dict-of-AoD keyed by empty/non-empty value for *key*.

        :param TyAoD aod: Source array of dictionaries.
        :param Any key: Key to inspect.
        :param Any key_n: Dict key for empty-value group.
        :param Any key_y: Dict key for non-empty-value group.
        :returns: ``{key_n: [...], key_y: [...]}``.
        :rtype: TyDoAoD
        """
        _aod_y = []
        _aod_n = []
        for _dic in aod:
            if key in _dic:
                if _dic[key]:
                    _aod_y.append(_dic)
                else:
                    _aod_n.append(_dic)
            else:
                _aod_n.append(_dic)
        _doaod = {}
        _doaod[key_n] = _aod_n
        _doaod[key_y] = _aod_y
        return _doaod

    @staticmethod
    def sh_dod(aod: TyAoD, key: Any) -> TyDoD:
        """Convert to a dictionary-of-dictionaries keyed by *key* value.

        :param TyAoD aod: Source array of dictionaries.
        :param Any key: Key whose value becomes the outer dict key.
        :returns: Dictionary of dictionaries.
        :rtype: TyDoD
        """
        _dod: TyDoD = {}
        if not aod:
            return _dod
        for _dic in aod:
            if key not in _dic:
                continue
            _value = _dic[key]
            if _value not in _dod:
                _dod[_value] = {}
            for _k, _v in _dic.items():
                _dod[_value][_k] = _v
        return _dod

    @staticmethod
    def sh_unique(aod: TyAoD) -> TyAoD:
        """Deduplicate the array of dictionaries.

        :param TyAoD aod: Source array.
        :returns: Array with duplicate dictionaries removed.
        :rtype: TyAoD
        """
        aod_items = (tuple(d.items()) for d in aod)
        aod_deduplicated = set(aod_items)
        aod_new = [dict(i) for i in aod_deduplicated]
        return aod_new

    @staticmethod
    def split_by_value_is_not_empty(aod: TyAoD, key: Any) -> TyToAoD:
        """Split into two arrays: empty-value and non-empty-value for *key*.

        :param TyAoD aod: Source array.
        :param Any key: Key to inspect.
        :returns: Tuple ``(aod_empty, aod_non_empty)``.
        :rtype: TyToAoD
        """
        aod_y = []
        aod_n = []
        for _dic in aod:
            if key in _dic:
                if _dic[key]:
                    aod_y.append(_dic)
                else:
                    aod_n.append(_dic)
            else:
                aod_n.append(_dic)
        return aod_n, aod_y

    @staticmethod
    def sw_empty_value_found(
            aod: TyAoD, key: str, sw_raise: bool = False) -> TyBool:
        """Check if any dictionary has an empty value for *key*.

        :param TyAoD aod: Array of dictionaries.
        :param str key: Key to check.
        :param bool sw_raise: If ``True``, raise on empty (default ``False``).
        :returns: ``True`` if an empty value is found.
        :rtype: TyBool
        :raises ValueError: If *sw_raise* is ``True`` and empty value found.
        """
        for _dic in aod:
            if key not in _dic or not _dic[key]:
                if sw_raise:
                    msg = f"Value for key={key} for dictionary={_dic} is empty"
                    raise ValueError(msg)
                return True
        return False

    @staticmethod
    def sw_key_value_found(aod: TnAoD, key: Any, value: Any) -> bool:
        """Check if any dictionary has ``dic[key] == value``.

        :param TnAoD aod: Array of dictionaries.
        :param Any key: Key to look up.
        :param Any value: Value to match.
        :returns: ``True`` if a match is found.
        :rtype: bool
        """
        if not aod:
            return False
        for dic in aod:
            if key in dic and dic[key] == value:
                return True
        return False

    @staticmethod
    def to_aoa_of_keys_values(aod: TyAoD) -> TyAoA:
        """Convert to array of arrays with keys header row and value rows.

        :param TyAoD aod: Array of dictionaries.
        :returns: ``[[keys], [values], ...]``.
        :rtype: TyAoA
        """
        _aoa: TyAoA = []
        if not aod:
            return _aoa
        _aoa.append(list(aod[0].keys()))
        for _dic in aod:
            _aoa.append(list(_dic.values()))
        return _aoa

    @staticmethod
    def to_aoa_of_values(aod: TyAoD) -> TyAoA:
        """Convert to array of arrays (values only, no keys header).

        :param TyAoD aod: Array of dictionaries.
        :returns: ``[[values], ...]``.
        :rtype: TyAoA
        """
        _aoa: TyAoA = []
        if aod == []:
            return _aoa
        for _dic in aod:
            _aoa.append(list(_dic.values()))
        return _aoa

    @staticmethod
    def to_aoa(aod: TnAoD, sw_keys: TyBool = True, sw_values: TyBool = True) -> TnAoA:
        """Convert to array of arrays, optionally including keys and/or values.

        :param TnAoD aod: Array of dictionaries.
        :param TyBool sw_keys: Include keys as first row (default ``True``).
        :param TyBool sw_values: Include value rows (default ``True``).
        :returns: Array of arrays, or ``None`` if *aod* is empty.
        :rtype: TnAoA
        """
        if not aod:
            return None
        _aoa: TyAoA = []
        if sw_keys:
            _aoa.append(list(aod[0].keys()))
        if sw_values:
            for _dic in aod:
                _aoa.append(list(_dic.values()))
        return _aoa

    @staticmethod
    def to_arr_of_key_values(aod: TyAoD, key: Any) -> TyArr:
        """Extract all values for *key* from every dictionary.

        :param TyAoD aod: Array of dictionaries.
        :param Any key: Key to extract.
        :returns: Array of values.
        :rtype: TyArr
        """
        arr: TyArr = []
        if aod == []:
            return arr
        for _dic in aod:
            for (_k, _v) in _dic.items():
                if _k == key:
                    arr.append(_v)
        return arr

    @staticmethod
    def to_dic_by_dic(
            aod: TnAoD, d_meta: TnDic = None) -> TyDic:
        """Convert to dictionary using meta-dict with ``'k'`` and ``'v'`` keys.

        :param TnAoD aod: Array of dictionaries.
        :param TnDic d_meta: ``{'k': key_name, 'v': value_name}``.
        :returns: Flat dictionary.
        :rtype: TyDic
        """
        if not aod or not d_meta:
            return {}
        dic_new: TyDic = {}
        for _dic in aod:
            dic_new[_dic[d_meta['k']]] = _dic[d_meta['v']]
        return dic_new

    @staticmethod
    def to_dic_by_ix(aod: TnAoD) -> TyDic:
        """Convert to dictionary using first item-pair of each dict.

        :param TnAoD aod: Array of dictionaries.
        :returns: ``{first_value: second_value}`` for each dict.
        :rtype: TyDic
        """
        if not aod:
            return {}
        dic_new: TyDic = {}
        for _dic in aod:
            _aot: TyAoT = list(_dic.items())
            if len(_aot) < 2:
                continue
            dic_new[_aot[0][1]] = _aot[1][1]
        return dic_new

    @staticmethod
    def to_doaod_by_key(aod: TnAoD, key: TnAny) -> TyDoAoD:
        """Group dictionaries by *key* value into a dict-of-AoD.

        :param TnAoD aod: Array of dictionaries.
        :param TnAny key: Grouping key.
        :returns: ``{value: [dicts...]}``.
        :rtype: TyDoAoD
        """
        doaod: TyDoAoD = {}
        if not aod:
            return doaod
        if not key:
            return doaod
        for dic in aod:
            if key in dic:
                value = dic[key]
                if value not in doaod:
                    doaod[value] = []
                doaod[value].append(dic)
        return doaod

    @classmethod
    def to_unique_by_key(cls, aod: TyAoD, key: Any) -> TyAoD:
        """Deduplicate by keeping first occurrence of each *key* value.

        :param TyAoD aod: Array of dictionaries.
        :param Any key: Key for uniqueness check.
        :returns: Deduplicated array.
        :rtype: TyAoD
        """
        aod_new: TyAoD = []
        for _dic in aod:
            _value = _dic.get(key)
            if not _value:
                continue
            if cls.sw_key_value_found(aod_new, key, _value):
                continue
            aod_new.append(_dic)
        return aod_new

    @staticmethod
    def union_distinct(aod0: TnAoD, aod1: TnAoD) -> TnAoD:
        """Union of two arrays of dictionaries with distinct elements.

        :param TnAoD aod0: First array.
        :param TnAoD aod1: Second array.
        :returns: Merged array without duplicates from *aod1*.
        :rtype: TnAoD
        """
        if aod0 is None:
            return aod1
        if aod1 is None:
            return aod0
        _aod = aod0 + [item for item in aod1 if item not in aod0]
        return _aod
