# coding=utf-8
from collections.abc import Callable, Iterator, Iterable
from typing import Any, Literal

import os

TyArr = list[Any]
TyCallable = Callable[..., Any]
TyIterable = Iterable
TyDic = dict[Any, Any]
TyNum = int | float
TyStr = str
TyTup = tuple[Any, ...]
TyArrTup = TyArr | TyTup
TyArrStr = TyArr | str
TyTupArr = TyTup | TyArr

TnAny = None | Any
TnArr = None | TyArr
TnBool = None | bool
TnInt = None | int
TnStr = None | str
TnTup = None | TyTup
TnArrStr = None | TyArr | str
TnArrTup = None | TyArr | TyTup
TnDic = None | TyDic


class Arr:
    """Manage Array.

    A static utility class providing operations on generic arrays (lists).
    """

    @staticmethod
    def append(arr: TnArr, item: TnAny) -> None:
        """Append item to the array if both are non-empty.

        :param TnArr arr: Target array.
        :param TnAny item: Item to append.
        :returns: None
        """
        if not arr:
            return
        if not item:
            return
        arr.append(item)

    @staticmethod
    def append_unique(arr: TyArr, item: TnAny) -> None:
        """Append item to the array only if not already present.

        :param TyArr arr: Target array.
        :param TnAny item: Item to append.
        :returns: None
        """
        if not arr:
            return
        if not item:
            return
        if item not in arr:
            arr.append(item)

    @staticmethod
    def apply_function(arr: TyTupArr, function: TyCallable, **kwargs) -> TyTupArr:
        """Apply *function* with keyword arguments to each element.

        :param TyTupArr arr: Source array or tuple.
        :param TyCallable function: Callable applied to each element.
        :param kwargs: Additional keyword arguments forwarded to *function*.
        :returns: New array with transformed elements.
        :rtype: TyTupArr
        """
        if not arr:
            return arr
        arr_new: TyArr = []
        for item in arr:
            _item = function(item, **kwargs)
            arr_new.append(_item)
        return arr_new

    @staticmethod
    def apply_replace(arr: TyTupArr, source: TyStr, target: TyStr) -> TyTupArr:
        """Replace *source* substring by *target* in every string element.

        :param TyTupArr arr: Source array or tuple.
        :param TyStr source: Substring to find.
        :param TyStr target: Replacement substring.
        :returns: New array with replacements applied.
        :rtype: TyTupArr
        """
        if not arr:
            return arr
        arr_new: TyArr = []
        for item in arr:
            if isinstance(item, str):
                item = item.replace(source, target)
            arr_new.append(item)
        return arr_new

    @staticmethod
    def apply_str(arr: TyTupArr) -> TyTupArr:
        """Convert every non-None element to its string representation.

        :param TyTupArr arr: Source array or tuple.
        :returns: New array of stringified elements (None kept as-is).
        :rtype: TyTupArr
        """
        if not arr:
            return arr
        arr_new: TyArr = []
        for item in arr:
            if item is None:
                arr_new.append(item)
            else:
                arr_new.append(str(item))
        return arr_new

    @staticmethod
    def encode(arr: TnArr) -> TnStr:
        """Join elements with spaces and percent-encode the result.

        :param TnArr arr: Array of strings.
        :returns: Percent-encoded string, or ``None`` if *arr* is empty.
        :rtype: TnStr
        """
        if arr is None:
            return None
        arr_joined: str = ' '.join(arr)
        if arr_joined == '':
            return None
        return f"{arr_joined} ".replace(' ', '%20')

    @staticmethod
    def extend(arr0: TnArr, arr1: TnArr) -> TnArr:
        """Extend *arr0* with *arr1* in-place, handling ``None`` inputs.

        :param TnArr arr0: First array (extended in-place).
        :param TnArr arr1: Second array.
        :returns: The merged array, or ``None`` if both inputs are ``None``.
        :rtype: TnArr
        """
        if arr0 is None:
            if arr1 is None:
                return None
            return arr1
        if arr1 is None:
            return arr0
        arr0.extend(arr1)
        return arr0

    @staticmethod
    def get_key_value(
            arr: TyArr, ix: int = 0, default: str = '', value: str = 'Title'
    ) -> TnStr:
        """Get the next array item if current item matches *value*.

        :param TyArr arr: Source array.
        :param int ix: Index to check (default ``0``).
        :param str default: Default return value (default ``''``).
        :param str value: Expected value at *ix* (default ``'Title'``).
        :returns: Value at ``ix + 1`` if match, *default* otherwise, or ``None``.
        :rtype: TnStr
        """
        if not arr:
            return None
        if ix >= len(arr):
            return default
        _value_ix = arr[ix].replace('\n', ' ').strip()
        if _value_ix == value:
            ix_next = ix + 1
            if ix_next >= len(arr):
                return default
            new_value: TnStr = arr[ix_next].replace('\n', ' ').strip()
        else:
            new_value = default
        return new_value

    @staticmethod
    def get_text(arr: TyArr, ix: int = 0, default: str = '') -> TnStr:
        """Get stripped text at index *ix*, with newlines replaced by spaces.

        :param TyArr arr: Source array of strings.
        :param int ix: Index (default ``0``).
        :param str default: Default if *ix* out of range (default ``''``).
        :returns: Cleaned text or *default*.
        :rtype: TnStr
        """
        if not arr:
            return None
        if ix >= len(arr):
            return default
        _s: TnStr = arr[ix].replace('\n', ' ').strip()
        return _s

    @staticmethod
    def get_text_split(
            arr: TyArr,
            ix0: int = 0,
            default: str = '',
            ix1: int = 0,
            separator: str = 'see less') -> TnStr:
        """Get text at *ix0*, split by *separator*, and return part at *ix1*.

        :param TyArr arr: Source array.
        :param int ix0: Index into the array (default ``0``).
        :param str default: Default if out of range (default ``''``).
        :param int ix1: Index into the split result (default ``0``).
        :param str separator: Split separator (default ``'see less'``).
        :returns: The selected substring, *default*, or ``None``.
        :rtype: TnStr
        """
        if not arr:
            return None
        if ix0 >= len(arr):
            return default
        parts = arr[ix0].replace('\n', ' ').strip().split(separator)
        if ix1 >= len(parts):
            return default
        _s: TnStr = parts[ix1].strip()
        return _s

    @staticmethod
    def get_item(
            arr: TnArr, ix: TnInt = None, default: TnStr = None) -> TnAny:
        """Get item at index *ix* with bounds checking.

        :param TnArr arr: Source array.
        :param TnInt ix: Index (default ``None``).
        :param TnStr default: Default if out of range.
        :returns: Item at *ix*, *default*, or ``None``.
        :rtype: TnAny
        """
        if ix is None:
            return None
        if not arr:
            return None
        if ix < 0 or ix >= len(arr):
            return default
        return arr[ix]

    @staticmethod
    def intersection(arr0: TyArr, arr1: TyArr) -> TyArr:
        """Return set intersection of two arrays as a list.

        :param TyArr arr0: First array.
        :param TyArr arr1: Second array.
        :returns: List of common elements.
        :rtype: TyArr
        """
        if not arr0:
            return arr1 if arr1 else []
        if not arr1:
            return arr0
        return list(set(arr0).intersection(set(arr1)))

    @staticmethod
    def is_empty(arr: TnArr) -> Literal[True, False]:
        """Check whether the array is ``None`` or empty.

        :param TnArr arr: Array to check.
        :returns: ``True`` if *arr* is ``None`` or ``[]``.
        :rtype: bool
        """
        if arr is None:
            return True
        if isinstance(arr, (list, tuple)):
            if arr == []:
                return True
        return False

    @classmethod
    def is_not_empty(cls, arr: TnArr) -> Literal[True, False]:
        """Check whether the array is neither ``None`` nor empty.

        :param TnArr arr: Array to check.
        :returns: ``True`` if *arr* has elements.
        :rtype: bool
        """
        return not cls.is_empty(arr)

    @staticmethod
    def join_not_none(
            arr: Iterable[str | None], separator: str = ' ') -> TnStr:
        """Join non-``None`` elements with *separator*.

        :param Iterable[str|None] arr: Iterable of strings or ``None``.
        :param str separator: Join separator (default ``' '``).
        :returns: Joined string.
        :rtype: TnStr
        """
        return separator.join(filter(None, arr))

    @staticmethod
    def length(arr: TnArr) -> TyNum:
        """Return the length of the array, or ``0`` if empty/``None``.

        :param TnArr arr: Array.
        :returns: Number of elements.
        :rtype: TyNum
        """
        if not arr:
            return 0
        return len(arr)

    @staticmethod
    def makedirs(dirs: TnArr, **kwargs) -> None:
        """Create directories for each path in *dirs*.

        :param TnArr dirs: Array of directory paths.
        :param kwargs: Forwarded to :func:`os.makedirs`.
        :returns: None
        """
        if not dirs:
            return
        kwargs.setdefault('exist_ok', True)
        for dir in dirs:
            os.makedirs(dir, **kwargs)

    @staticmethod
    def sh_dic_from_keys_values(keys: TyArr, values: TyArr) -> TyDic:
        """Create a dictionary by zipping *keys* and *values*.

        :param TyArr keys: Array of keys.
        :param TyArr values: Array of values.
        :returns: Zipped dictionary, or ``{}`` if inputs are empty.
        :rtype: TyDic
        """
        if not keys:
            return {}
        if not values:
            return {}
        return dict(zip(keys, values, strict=False))

    @staticmethod
    def sh_dic_zip(keys: Iterable[Any], values: Iterable[Any]) -> TyDic:
        """Create a dictionary by zipping two iterables.

        :param Iterable[Any] keys: Iterable of keys.
        :param Iterable[Any] values: Iterable of values.
        :returns: Zipped dictionary.
        :rtype: TyDic
        """
        if not keys:
            return {}
        if not values:
            return {}
        return dict(zip(keys, values, strict=False))

    @staticmethod
    def sh_item(arr: TnArr, ii: int) -> TnAny:
        """Return item at index *ii*, or ``None`` if out of bounds.

        :param TnArr arr: Source array.
        :param int ii: Index.
        :returns: Item or ``None``.
        :rtype: TnAny
        """
        if not arr:
            return None
        if ii < 0 or ii >= len(arr):
            return None
        return arr[ii]

    @staticmethod
    def sh_item_lower(arr: TnArr, ii: int) -> TnAny:
        """Return lower-cased item at index *ii*.

        :param TnArr arr: Source array.
        :param int ii: Index.
        :returns: Lower-cased item, or ``None`` if out of bounds or not a string.
        :rtype: TnAny
        """
        if arr is None or not arr:
            return None
        if ii < 0 or ii >= len(arr):
            return None
        item = arr[ii]
        return item.lower() if isinstance(item, str) else item

    @staticmethod
    def sh_item_if(string: TnStr, arr: TnArr, ii: int) -> TnAny:
        """Return item at *ii* only if *string* is contained in it.

        :param TnStr string: Substring to search for.
        :param TnArr arr: Source array.
        :param int ii: Index.
        :returns: Item if *string* found, otherwise ``None``.
        :rtype: TnAny
        """
        if arr is None or not arr:
            return None
        if string is None:
            return None
        if ii < 0 or ii >= len(arr):
            return None
        item: Any = arr[ii]
        if isinstance(item, str) and string in item:
            return item
        return None

    @classmethod
    def sh_item0(cls, arr: TyArr) -> Any:
        """Return the first element, or ``None`` if array is empty.

        :param TyArr arr: Source array.
        :returns: First element or ``None``.
        :rtype: Any
        """
        if arr == []:
            return None
        return cls.sh_item(arr, 0)

    @classmethod
    def sh_item0_if(cls, string: str, arr: TyArr) -> Any:
        """Return first element only if it contains *string*.

        :param str string: Substring to search for.
        :param TyArr arr: Source array.
        :returns: First element if match, otherwise ``None``.
        :rtype: Any
        """
        return cls.sh_item_if(string, arr, 0)

    @staticmethod
    def sh_items_str(arr: TnArr, start: int, end: int) -> TnStr:
        """Join a slice ``arr[start:end]`` into a single space-separated string.

        :param TnArr arr: Source array.
        :param int start: Start index.
        :param int end: End index (exclusive).
        :returns: Joined string, or ``None`` if array is empty.
        :rtype: TnStr
        """
        if arr is None:
            return arr
        if arr == []:
            return None
        return ' '.join(arr[start:end])

    @staticmethod
    def to_dic(arr: TyArr, keys: TyArr) -> TyDic:
        """Convert an array to a dictionary using a parallel *keys* array.

        :param TyArr arr: Array of values.
        :param TyArr keys: Array of keys (same length as *arr*).
        :returns: Dictionary mapping ``keys[i] -> arr[i]``.
        :rtype: TyDic
        :raises ValueError: If *keys* is shorter than *arr*.
        """
        if not arr:
            return {}
        if not keys or len(keys) < len(arr):
            raise ValueError(
                f"keys length ({len(keys) if keys else 0}) "
                f"must be >= arr length ({len(arr)})"
            )
        dic = {}
        for ii, item in enumerate(arr):
            dic[keys[ii]] = item
        return dic

    @staticmethod
    def sh_subarray(arr: TyArr, from_: int, to_: int) -> TyArr:
        """Return a sub-array ``arr[from_:to_+1]`` with bounds clamping.

        :param TyArr arr: Source array.
        :param int from_: Start index (clamped to 0).
        :param int to_: End index (inclusive, clamped to array length).
        :returns: Sub-array slice.
        :rtype: TyArr
        """
        if not arr:
            return arr
        if from_ >= to_:
            return arr
        from_new: int = max(0, from_)
        len_arr = len(arr)
        if to_ < len_arr:
            to_new: int = to_ + 1
        else:
            to_new = len_arr
        return arr[from_new:to_new]

    @staticmethod
    def sh_items_in_dic(arr: TnArr, dic: TnDic) -> TyArr:
        """Collect dictionary values for keys present in *arr*.

        :param TnArr arr: Array of keys.
        :param TnDic dic: Source dictionary.
        :returns: Array of matched values.
        :rtype: TyArr
        """
        arr_new: TyArr = []
        if not arr:
            return arr_new
        if not dic:
            return arr_new
        for _key in arr:
            if _key in dic:
                arr_new.append(dic[_key])
        return arr_new

    @staticmethod
    def union(arr1: TnArr, arr2: TnArr) -> TnArr:
        """Union of two arrays, preserving order and avoiding duplicates from *arr2*.

        :param TnArr arr1: First array.
        :param TnArr arr2: Second array.
        :returns: Merged array.
        :rtype: TnArr
        """
        if arr1 is None:
            return arr2
        if arr2 is None:
            return arr1
        return arr1 + [item for item in arr2 if item not in arr1]

    @staticmethod
    def yield_items(arr: TyArr, obj: Any) -> Iterator[TnTup]:
        """Yield ``(item, obj)`` tuples for every element in *arr*.

        :param TyArr arr: Source array.
        :param Any obj: Object paired with each item.
        :yields: Tuple ``(item, obj)`` or ``None`` if *arr* is ``None``.
        :rtype: Iterator[TnTup]
        """
        if arr is None:
            yield None
            return
        for _item in arr:
            yield (_item, obj)
