from __future__ import annotations

import collections
import dataclasses
import glob
import typing
from collections.abc import Iterable, MutableMapping
from jsonpath_rust_bindings import Finder

import farmhash
import jsonlines
import orjson
import slugify
from box import Box
from pydantic import BaseModel
from pydantic.main import IncEx
from ._functional import filter_nulls, try_chain

# Type aliases
DictLike = typing.Union[dict, "DotDict"]
D = typing.TypeVar("D", bound=dict | MutableMapping)
TKey = str | int
TKeys = tuple[TKey, ...]
TVal = str | int | float | bool | None

# Dictionary operations

T = typing.TypeVar("T", bound=dict | list)


def sort_object_keys(obj: T) -> T:
    if isinstance(obj, dict):
        return {k: sort_object_keys(v) for k, v in sorted(obj.items())}
    if isinstance(obj, list):
        return [sort_object_keys(i) for i in obj]
    return obj


def sort_if_sortable(obj: T) -> T:
    try:
        return sorted(obj)
    except TypeError:
        return obj


_fallback_serializer = try_chain(
    lambda data: farmhash.hash64(
        orjson.dumps(sort_object_keys(filter_nulls(data)), default=serialize)
    ),
    lambda data: farmhash.hash64(str(hash(data))),
)


def hash_serialize(data: typing.Any) -> typing.Any:
    if hasattr(data, "__hash__") and getattr(data, "__hash__"):
        return hash(data)
    elif isinstance(data, BaseModel):
        return farmhash.hash64(
            orjson.dumps(
                sort_object_keys(filter_nulls(data.model_dump(mode="json"))),
                default=hash_serialize,
            )
        )
    elif isinstance(data, dict):
        return farmhash.hash64(
            orjson.dumps(sort_object_keys(filter_nulls(data)), default=hash_serialize)
        )
    elif isinstance(data, list):
        return farmhash.hash64(
            orjson.dumps(sort_if_sortable(filter_nulls(data)), default=hash_serialize)
        )
    elif isinstance(data, (tuple, set)):
        return hash_serialize(sort_if_sortable(filter_nulls(list(data))))
    else:
        return _fallback_serializer(data)


def serialize(data: typing.Any) -> typing.Any:
    """
    Serialize
    """
    if hasattr(data, "__hash__") and getattr(data, "__hash__"):
        return hash(data)
    elif isinstance(data, dict):
        return farmhash.hash64(
            orjson.dumps(sort_object_keys(filter_nulls(data)), default=serialize)
        )
    elif isinstance(data, list):
        return farmhash.hash64(
            orjson.dumps(sort_if_sortable(filter_nulls(data)), default=serialize)
        )
    elif isinstance(data, (tuple, set)):
        return serialize(sort_if_sortable(filter_nulls(list(data))))
    elif isinstance(data, bytes):
        return hash(data)
    else:
        return str(data)


def object_farmhash(obj: BaseModel | typing.Any, exclude_keys: IncEx = None) -> int:
    """
    Generate a consistent FarmHash64 hash from an object (include Pydantic models)

    :param obj: A Pydantic model instance
    :return: 64-bit FarmHash as an integer
    """

    if isinstance(obj, BaseModel):
        obj = obj.model_dump(mode="json", exclude_none=True, exclude=exclude_keys)

    serialized_data = hash_serialize(obj)
    data_str = str(serialized_data)
    return farmhash.hash64(data_str)


def merge_dicts(
    dicts: list[dict],
    keep_unique_values: bool = False,
    keep_unique_value_keys: list = [],
) -> dict:
    """
    Merge a list of dictionaries into a single dictionary.

    Args:
        dicts (list[dict]): List of dictionaries to merge.
        keep_unique_values (bool, optional): If True, keep all unique values for each key. Defaults to False.
        keep_unique_value_keys (list, optional): List of keys for which to keep unique values. Defaults to [].

    Returns:
        dict: Merged dictionary.
    """
    merged: typing.Dict = {}
    for d in deduplicate_dicts(dicts):
        for key, value in d.items():
            if isinstance(value, dict):
                if key in merged:
                    if isinstance(merged[key], list):
                        merged[key] = {k: v for k, v in enumerate(merged[key])}
                    merged_dict = merge_dicts(
                        [merged[key], value],
                        keep_unique_values=keep_unique_values,
                        keep_unique_value_keys=keep_unique_value_keys,
                    )
                    merged[key] = merged_dict
                else:
                    merged[key] = value
            elif isinstance(value, list):
                if key in merged:
                    for elem in value:
                        if elem not in merged[key]:
                            merged[key].append(elem)
                else:
                    merged[key] = value
            else:
                if value is not None and value != "":
                    if (
                        keep_unique_values and key in merged
                    ) or key in keep_unique_value_keys:
                        if not isinstance(merged[key], list):
                            merged[key] = [merged[key]]

                        if value not in merged[key]:
                            merged[key].append(value)

                        if len(merged[key]) == 1:
                            merged[key] = merged[key][0]
                    else:
                        merged[key] = value

    return {k: v for k, v in merged.items() if v is not None and v != ""}


def deduplicate_dicts(dicts: list[dict]) -> list[dict]:
    """
    Remove duplicate dictionaries from a list of dictionaries.

    Args:
        dicts (list[dict]): List of dictionaries to deduplicate.

    Returns:
        list[dict]: List of unique dictionaries.
    """
    new_dicts = []
    for d in dicts:
        if d not in new_dicts:
            new_dicts.append(d)
    return new_dicts


def clean_dict(d: typing.Union[dict, list, set]) -> typing.Union[dict, list]:
    """
    Convert defaultdict to plain dictionary recursively and handle unhashable types.

    Args:
        d (Union[dict, list, set]): Input data structure to clean.

    Returns:
        Union[dict, list]: Cleaned data structure.
    """
    if isinstance(d, dict):
        return {k: clean_dict(v) for k, v in d.items()}
    elif isinstance(d, (list, set)):
        return [clean_dict(v) for v in d]
    else:
        return d


def recursive_remove_nones(obj: dict) -> dict:
    """
    Recursively remove None values from a dictionary.

    Args:
        obj (dict): Input dictionary.

    Returns:
        dict: Dictionary with None values removed.
    """
    _new = {}
    for k, v in obj.items():
        if isinstance(v, dict):
            _new[k] = recursive_remove_nones(v)
        elif v is not None:
            _new[k] = v
    return _new


def set_defaults(__base: dict | None = None, **kwargs) -> dict:
    """
    Set default values in a dictionary.

    Args:
        __base (dict | None, optional): Base dictionary to update. Defaults to None.
        **kwargs: Key-value pairs to set as defaults.

    Returns:
        dict: Updated dictionary with default values.
    """
    if not __base:
        __base = {}
    for k, v in kwargs.items():
        if v:
            __base[k] = v
    return __base


# List operations


def merge_lists(lists: list[list]) -> list:
    """
    Merge multiple lists, removing duplicates.

    Args:
        lists (list[list]): List of lists to merge.

    Returns:
        list: Merged list with unique elements.
    """
    merged = []
    for _list in lists:
        for elem in _list:
            if elem not in merged:
                merged.append(elem)
    return merged


def flatten_list(lst: list) -> list:
    """
    Flatten a list of lists.

    Args:
        lst (list): List to flatten.

    Returns:
        list: Flattened list.
    """
    flattened = []
    for item in lst:
        if isinstance(item, list):
            flattened.extend(flatten_list(item))
        else:
            flattened.append(item)
    return flattened


# Object conversion and manipulation


def recursive_to_dict(o: typing.Any) -> typing.Any:
    """
    Recursively convert an object to a dictionary.

    Args:
        o (typing.Any): Object to convert.

    Returns:
        typing.Any: Converted object.
    """
    if isinstance(o, dict):
        return {k: recursive_to_dict(v) for k, v in o.items()}
    elif hasattr(o, "__dict__"):
        return recursive_to_dict(o.__dict__)
    elif isinstance(o, list):
        return [recursive_to_dict(v) for v in o]
    else:
        return o


def to_dict(obj: typing.Any) -> dict:
    """
    Convert various object types to a dictionary.

    Args:
        obj (typing.Any): Object to convert.

    Returns:
        dict: Dictionary representation of the object.
    """
    if hasattr(obj, "model_export"):
        return obj.model_export()
    elif dataclasses.is_dataclass(obj):
        if isinstance(obj, type):
            # If obj is a dataclass type, not an instance
            return {
                field.name: getattr(field, "default", None)
                for field in dataclasses.fields(obj)
            }
        else:
            # If obj is a dataclass instance
            return dataclasses.asdict(obj)
    elif hasattr(obj, "json"):
        return orjson.loads(obj.json())
    elif hasattr(obj, "dict"):
        return obj.dict()
    elif hasattr(obj, "as_dict"):
        return obj.as_dict()
    elif hasattr(obj, "_asdict"):
        return obj._asdict()
    elif hasattr(obj, "to_dict"):
        return obj.to_dict()
    else:
        raise TypeError(f"Object of type {type(obj)} is not convertible to dict")


def map_object(obj: dict, **mappers) -> dict:
    """
    Apply functions to specific keys in a dictionary.

    Args:
        obj (dict): Input dictionary.
        **mappers: Functions to apply to specific keys.

    Returns:
        dict: Dictionary with mapped values.
    """
    _new = {}
    for k, v in obj.items():
        if k in mappers and v:
            if callable(mappers[k]):
                _v = mappers[k](v)
                if _v:
                    _new[k] = _v
                else:
                    _new[k] = v
            else:
                _new[k] = v
        else:
            _new[k] = v
    return _new


# JSON and file operations


def get_records_from_glob(
    path: str, limit: int | None = None, filters: list[typing.Callable] = []
) -> list[dict]:
    """
    Get records from JSON files matching a glob pattern.

    Args:
        path (str): Glob pattern for JSON files.
        limit (int | None, optional): Maximum number of records to return. Defaults to None.
        filters (list[typing.Callable], optional): List of filter functions to apply. Defaults to [].

    Returns:
        list[dict]: List of records from JSON files.
    """
    records = []
    for file in glob.glob(path):
        for record in jsonlines.open(file, loads=orjson.loads):
            if all([f(record) for f in filters]) or len(filters) == 0:
                records.append(record)
            if limit and len(records) >= limit:
                return records

    return records


def get_data_from_records(
    records: list[dict], path: str
) -> typing.Generator[typing.Any, typing.Any, None]:
    """
    Get nested data from a list of records using a dot-separated path.

    Args:
        records (list[dict]): List of records to search.
        path (str): Dot-separated path to the desired data.

    Yields:
        typing.Any: Nested data from records.
    """
    for record in records:
        o = recursive_get(record, *path.split("."))
        if o:
            yield o


def expand_nested_json(obj: dict) -> dict:
    """
    Expand nested JSON strings within a dictionary.

    Args:
        obj (dict): Dictionary containing nested JSON strings.

    Returns:
        dict: Dictionary with expanded nested JSON.

    Raises:
        ValueError: If the expanded object is not a dictionary.
    """

    def _expand_nested_json(obj) -> dict | list:
        if isinstance(obj, dict):
            for k, v in obj.items():
                if isinstance(v, str):
                    try:
                        _obj = orjson.loads(v)
                        obj[k] = _expand_nested_json(_obj)
                    except ValueError:
                        obj[k] = v
                else:
                    obj[k] = _expand_nested_json(v)
        elif isinstance(obj, list):
            for i, v in enumerate(obj):
                if isinstance(v, str):
                    try:
                        _obj = orjson.loads(v)
                        obj[i] = _expand_nested_json(_obj)
                    except ValueError:
                        obj[i] = v
                else:
                    obj[i] = _expand_nested_json(v)
        return obj

    _obj = _expand_nested_json(obj)
    if isinstance(_obj, dict):
        return _obj
    else:
        raise ValueError(f"Expanded object {_obj=} not a dictionary")


# Nested data access and manipulation


def recursive_default(obj: dict, *keys) -> typing.Any:
    """
    Get a value from nested dictionaries using multiple keys.

    Args:
        obj (dict): Dictionary to search.
        *keys: Keys to search for.

    Returns:
        typing.Any: Value found or None if not found.
    """
    for i, key in enumerate(keys):
        if isinstance(obj, dict):
            if key in obj:
                obj = obj[key]
            else:
                # If it's the last key and not found, return the entire object
                return obj if i == len(keys) - 1 else None
        else:
            return None
    return obj


def recursive_get(obj: dict | None, *keys) -> typing.Any:
    """
    Get a value from nested dictionaries using multiple keys.

    Args:
        obj (dict | None): Dictionary to search.
        *keys: Keys to search for.

    Returns:
        typing.Any: Value found or None if not found.
    """
    if obj is None:
        return None
    if len(keys) == 0:
        return obj

    if isinstance(keys[0], list):
        _obj = recursive_default(obj, *keys[0])
        if isinstance(_obj, dict):
            return recursive_get(_obj, *keys[1:])
        else:
            return _obj

    if keys[0] in obj:
        return recursive_get(obj.get(keys[0]), *keys[1:])
    return None


def recursive_convert_lists(obj: dict | list | typing.Any) -> dict | list | typing.Any:
    """
    Convert nested objects with numeric string keys to lists.

    Args:
        obj (dict): Dictionary to convert.

    Returns:
        dict: Dictionary with converted nested objects.
    """
    # if isinstance(obj, dict):
    #     for k, v in obj.items():
    #         if isinstance(v, dict) and all(
    #             [isinstance(k, str) and k.isdigit() for k in v.keys()]
    #         ):
    #             obj[k] = [recursive_convert_lists(v) for v in v.values()]
    #         elif isinstance(v, dict):
    #             obj[k] = recursive_convert_lists(v)
    # return obj
    if isinstance(obj, dict):
        if all(isinstance(k, str) and k.isdigit() for k in obj.keys()):
            return [
                recursive_convert_lists(v)
                for _, v in sorted(obj.items(), key=lambda x: int(x[0]))
            ]
        return {k: recursive_convert_lists(v) for k, v in obj.items()}
    elif isinstance(obj, list):
        return [recursive_convert_lists(v) for v in obj]
    return obj


# Index and deindex operations

# def index_object(obj: dict, keys_as_tuples: bool = False) -> dict:
#     """
#     Create an indexed representation of a nested object.

#     Args:
#         obj (dict): Object to index.
#         keys_as_tuples (bool, optional): Whether to use tuples as keys. Defaults to False.

#     Returns:
#         dict: Indexed representation of the object.
#     """
#     def _index_object(obj, path=None):
#         if path is None:
#             path = []
#         if isinstance(obj, dict):
#             for k, v in obj.items():
#                 yield from _index_object(v, path + [k])
#         elif isinstance(obj, list):
#             for i, v in enumerate(obj):
#                 yield from _index_object(v, path + [f'[{i}]'])
#         else:
#             yield tuple(path), obj

#     return {
#         path_tuple_to_string(path) if not keys_as_tuples else path: value
#         for path, value in _index_object(obj)
#     }


def index_object(obj: dict, keys_as_tuples: bool = False) -> dict:
    def _index_object(obj, path=None):
        if path is None:
            path = []
        if isinstance(obj, dict):
            for k, v in obj.items():
                yield from _index_object(v, path + [k])
        elif isinstance(obj, list):
            for i, v in enumerate(obj):
                if isinstance(v, (dict, list)):
                    yield from _index_object(v, path + [f"[{i}]"])
                else:
                    yield (tuple(path + [f"[{i}]"]), v)
        else:
            yield (tuple(path), obj)

    indexed = {path: value for path, value in _index_object(obj)}
    if not keys_as_tuples:
        return {path_tuple_to_string(path): value for path, value in indexed.items()}
    return indexed


# def index_object(obj: typing.Any, prefix: str = '') -> dict[str, typing.Any]:
#     if isinstance(obj, dict):
#         return {f"{prefix}{key}": index_object(value, f"{prefix}{key}.")
#                 for key, value in obj.items() if value is not None}
#     elif isinstance(obj, list):
#         return {f"{prefix}[{i}]": index_object(value, f"{prefix}[{i}].")
#                 for i, value in enumerate(obj) if value is not None}
#     else:
#         return obj

# def deindex_object(obj: dict[str, TVal]) -> TReturn:
#     """
#     Convert an indexed object back to its nested form.

#     Args:
#         obj (dict[str, TVal]): Indexed object to convert.


#     Returns:
#         dict[str, typing.Any]: Nested form of the object.
#     """
#     # return _deindex_object(
#     #     {path_string_to_tuple(key): value for key, value in obj.items()}
#     # )
#     return _deindex_object(
#         {path_string_to_tuple(key): value for key, value in obj.items()}
#     )
def deindex_object(obj: dict[str, TVal]) -> TReturn:
    def _set_nested(result, keys, value):
        current = result
        for i, key in enumerate(keys[:-1]):
            if key.startswith("[") and key.endswith("]"):
                index = int(key[1:-1])
                if not isinstance(current, dict):
                    current = {}
                    if i == 0:
                        result = current
                    else:
                        result[keys[i - 1]] = current
                if str(index) not in current:
                    current[str(index)] = {}
                current = current[str(index)]
            else:
                if key not in current:
                    current[key] = {}
                current = current[key]

        last_key = keys[-1]
        if last_key.startswith("[") and last_key.endswith("]"):
            index = int(last_key[1:-1])
            current[str(index)] = value
        else:
            current[last_key] = value
        return result

    def _dict_to_list(d):
        if isinstance(d, dict):
            if all(k.isdigit() for k in d.keys()):
                return [_dict_to_list(d[str(i)]) for i in range(len(d))]
            return {k: _dict_to_list(v) for k, v in d.items()}
        return d

    result = {}
    for path, value in obj.items():
        keys = path.replace("]", "").replace("[", ".").split(".")
        result = _set_nested(result, keys, value)

    return _dict_to_list(result)


def index_objects(objs: list) -> list[dict]:
    """
    Create indexed representations of a list of nested objects.

    Args:
        objs (list): List of objects to index.

    Returns:
        list[dict]: List of indexed representations of the objects.
    """
    return [index_object(obj) for obj in objs]


def deindex_objects(objs: list) -> list[TReturn]:
    """
    Convert a list of indexed objects back to their nested forms.

    Args:
        objs (list): List of indexed objects to convert.

    Returns:
        list[dict]: List of nested forms of the objects.
    """
    return [deindex_object(obj) for obj in objs]


# Helper functions for index/deindex operations


def _handle_serialized_key(key: str) -> str:
    """
    Handle serialized keys in indexed objects.

    Args:
        key (str): Key to handle.

    Returns:
        str: Processed key.
    """
    if "{" in key:
        try:
            d = orjson.loads(key)
            _key = ""
            for k, v in index_object(d).items():
                _key += f"{k}_{v}__"
            return _key
        except Exception:
            return slugify.slugify(key, separator="_")
    else:
        return key


def path_tuple_to_string(path: tuple) -> str:
    """
    Convert a path tuple to a string representation.

    Args:
        path (tuple): Path tuple to convert.

    Returns:
        str: String representation of the path.
    """
    _path = ".".join(
        f"[{i}]" if isinstance(i, int) else _handle_serialized_key(i) for i in path
    )
    return _path.replace(".[", "[")


def path_string_to_tuple(path: str) -> tuple:
    """
    Convert a string path to a tuple representation.

    Args:
        path (str): String path to convert.

    Returns:
        tuple: Tuple representation of the path.
    """
    # return tuple(int(i[1:-1]) if i.startswith("[") else i for i in path.split("."))
    return tuple(
        int(i[1:-1]) if i.startswith("[") and i.endswith("]") else i
        for i in path.split(".")
    )


TReturn = dict[TKey, typing.Any] | list[typing.Any] | str | int | float | bool | None

# def _deindex_object(obj: dict[tuple[TKey, ...], TVal]) -> TReturn:
#     """
#     Convert an indexed object back to its nested form.

#     Args:
#         obj (dict[tuple[TKey, ...], TVal]): Indexed object to convert.

#     Returns:
#         TReturn: Nested form of the object, which can be:
#             - A dictionary with string or int keys and any values
#             - A list of any values
#             - A primitive value (str, int, float, bool, or None)
#     """
#     result: dict[TKey, typing.Any] = {}
#     for key, value in obj.items():
#         _set_nested_dict(result, key, value)
#     return _numeric_dicts_to_list(result)


def _deindex_object(obj: dict[tuple[TKey, ...], TVal]) -> TReturn:
    result: dict[TKey, typing.Any] = {}
    for key, value in obj.items():
        current = result
        for i, k in enumerate(key[:-1]):
            if isinstance(k, str) and k.startswith("[") and k.endswith("]"):
                # This is a list index
                parent_key = key[i - 1]
                if parent_key not in current:
                    current[parent_key] = []
                index = int(k[1:-1])
                while len(current[parent_key]) <= index:
                    current[parent_key].append({})
                current = current[parent_key][index]
            else:
                if k not in current:
                    current[k] = {}
                current = current[k]
        last_key = key[-1]
        if (
            isinstance(last_key, str)
            and last_key.startswith("[")
            and last_key.endswith("]")
        ):
            parent_key = key[-2]
            index = int(last_key[1:-1])
            if parent_key not in current:
                current[parent_key] = []
            while len(current[parent_key]) <= index:
                current[parent_key].append(None)
            current[parent_key][index] = value
        else:
            current[last_key] = value
    return result


def _set_nested_dict(
    d: dict[TKey, typing.Any], keys: tuple[TKey, ...], value: typing.Any
) -> None:
    """
    Set a value in a nested dictionary using a tuple of keys.

    Args:
        d (dict[TKey, typing.Any]): Dictionary to modify.
        keys (tuple[TKey, ...]): Tuple of keys representing the path.
        value (typing.Any): Value to set.

    Raises:
        TypeError: If a non-integer key is used with a list.
    """
    for key in keys[:-1]:
        if isinstance(d, list):
            if not isinstance(key, int):
                raise TypeError(
                    f"List indices must be integers, not {type(key).__name__}"
                )
            while len(d) <= key:
                d.append({})
            if not isinstance(d[key], (dict, list)):
                d[key] = {}
            d = d[key]
        else:
            d = d.setdefault(key, {})

    if isinstance(d, list):
        if not isinstance(keys[-1], int):
            raise TypeError(
                f"List indices must be integers, not {type(keys[-1]).__name__}"
            )
        while len(d) <= keys[-1]:
            d.append({})
        d[keys[-1]] = value
    else:
        d[keys[-1]] = value


def _numeric_dicts_to_list(d: dict[TKey, TVal] | TVal) -> TReturn:
    """
    Convert numeric dictionaries to lists recursively.

    Args:
        d (dict[TKey, TVal] | TVal): Dictionary or value to convert.

    Returns:
        TReturn: Converted structure, which can be:
            - A dictionary with string or int keys and any values
            - A list of any values
            - A primitive value (str, int, float, bool, or None)
    """
    if isinstance(d, dict):
        if all(isinstance(k, str) and k.isdigit() for k in d.keys()):
            return [_numeric_dicts_to_list(d[str(i)]) for i in range(len(d))]
        return {k: _numeric_dicts_to_list(v) for k, v in d.items()}
    elif isinstance(d, list):
        return [_numeric_dicts_to_list(item) for item in d]
    else:
        return d


# Tree-like defaultdict
# ruff:  noqa: E731
tree = lambda: collections.defaultdict(tree)


def recursive_defaultdict_to_dict(obj: collections.defaultdict) -> dict:
    """
    Convert a recursive defaultdict to a regular dictionary.

    Args:
        obj (collections.defaultdict): Recursive defaultdict to convert.

    Returns:
        dict: Regular dictionary representation.
    """
    if isinstance(obj, collections.defaultdict):
        return {k: recursive_defaultdict_to_dict(v) for k, v in obj.items()}
    else:
        return obj


def convert_index_object_to_dict(index):
    result = tree()
    for path, val in index:
        d = result
        for element in path:
            d = d[element]
        d = val
    return result


# DotDict implementation


class DotDict(MutableMapping):
    def __init__(self, init_dict: DictLike | None = None, **kwargs: typing.Any):
        self.__dict__["_data"] = {}
        if init_dict:
            self.update(init_dict)
        self.update(kwargs)

    def __getattr__(self, key: str) -> typing.Any:
        return self[key]

    def __setattr__(self, key: str, value: typing.Any) -> None:
        self[key] = value

    def __getitem__(self, key: str) -> typing.Any:
        return self._data[key]

    def __setitem__(self, key: str, value: typing.Any) -> None:
        self._data[key] = self._convert(value)

    def __delitem__(self, key: str) -> None:
        del self._data[key]

    def __iter__(self) -> typing.Iterator[str]:
        return iter(self._data)

    def __len__(self) -> int:
        return len(self._data)

    def __repr__(self) -> str:
        return f"DotDict({self._data})"

    def _convert(self, value: typing.Any) -> typing.Any:
        if isinstance(value, dict) and not isinstance(value, DotDict):
            return DotDict(value)
        elif isinstance(value, list):
            return [self._convert(v) for v in value]
        return value

    def update(self, other: DictLike | None = None, **kwargs: typing.Any) -> None:
        if other is not None:
            for k, v in other.items():
                self[k] = v
        for k, v in kwargs.items():
            self[k] = v

    def to_dict(self) -> dict:
        def _to_dict(value):
            if isinstance(value, DotDict):
                return value.to_dict()
            elif isinstance(value, list):
                return [_to_dict(v) for v in value]
            return value

        return {k: _to_dict(v) for k, v in self._data.items()}


def find(json: dict, query: str):
    return Finder(json).find(query)


def find_non_empty(json: dict, query: str):
    return Finder(json).find_non_empty(query)


# class DotDict(MutableMapping):
#     """
#     A dictionary that supports attribute-style access (dot notation).

#     This class extends the standard python dictionary to allow accessing
#     and setting items using dot notation (e.g., `d.key` instead of `d['key']`).

#     Args:
#         init_dict (DictLike | None, optional): Initial dictionary to populate the DotDict. Defaults to None.
#         **kwargs: Additional key-value pairs to add to the DotDict.

#     Example:
#         >>> d = DotDict({'a': 1, 'b': {'c': 2}})
#         >>> d.a
#         1
#         >>> d.b.c
#         2
#         >>> d.d = 3
#         >>> d['d']
#         3
#     """

#     def __init__(self, init_dict: DictLike | None = None, **kwargs: typing.Any):
#         if init_dict:
#             super().update(init_dict)
#         super().update(kwargs)

#     def __getitem__(self, key: str) -> typing.Any:
#         return self.__dict__[key]

#     def __setitem__(self, key: str, value: typing.Any) -> None:
#         self.__dict__[key] = value

#     def __delitem__(self, key: str) -> None:
#         del self.__dict__[key]

#     def __iter__(self) -> typing.Iterator[str]:
#         return iter(self.__dict__)

#     def __len__(self) -> int:
#         return len(self.__dict__)

#     def __repr__(self) -> str:
#         if len(self) > 0:
#             return f"<{type(self).__name__}: {', '.join(sorted(repr(k) for k in self.keys()))}>"
#         else:
#             return f"<{type(self).__name__}>"

#     def get(self, key: str, default: typing.Any = None) -> typing.Any:
#         """
#         Get a value from the DotDict.

#         Args:
#             key (str): The key to retrieve.
#             default (typing.Any, optional): Default value to return if the key is not found. Defaults to None.

#         Returns:
#             typing.Any: The value associated with the key, or the default value if not found.
#         """
#         return super().get(key, default)

#     def copy(self) -> "DotDict":
#         """
#         Create a shallow copy of the DotDict.

#         Returns:
#             DotDict: A new DotDict instance with copied key-value pairs.
#         """
#         return type(self)(self.__dict__.copy())

#     def to_dict(self) -> dict:
#         """
#         Convert the DotDict to a regular dictionary.

#         Returns:
#             dict: A regular dictionary representation of the DotDict.
#         """
#         return typing.cast(dict, as_nested_dict(self, dct_class=dict))

# Helper functions for DotDict


def is_iterable(obj: typing.Any) -> typing.TypeGuard[Iterable]:
    """
    Check if an object is iterable (but not a string).

    Args:
        obj (typing.Any): Object to check.

    Returns:
        bool: True if the object is iterable and not a string, False otherwise.
    """
    return isinstance(obj, Iterable) and not isinstance(obj, (str, bytes))


def as_nested_dict(
    obj: MutableMapping[typing.Any, typing.Any] | Iterable[typing.Any],
    dct_class: type[MutableMapping] | typing.Callable[[], MutableMapping] = dict,
) -> MutableMapping[typing.Any, typing.Any] | typing.Any:
    """
    Convert a nested structure to the specified dictionary class.

    Args:
        obj (MutableMapping[typing.Any, typing.Any] | Iterable[typing.Any]): Object to convert.
        dct_class (type[MutableMapping] | Callable[[], MutableMapping], optional):
            Dictionary class or function that returns a new dictionary instance. Defaults to dict.

    Returns:
        MutableMapping[typing.Any, typing.Any] | typing.Any: Converted structure.
    """
    if isinstance(obj, (list, tuple, set)):
        return type(obj)(as_nested_dict(d, dct_class) for d in obj)
    elif isinstance(obj, Box):
        return dict(obj)
    elif isinstance(obj, MutableMapping):
        new_dict = dct_class() if callable(dct_class) else dct_class()
        for k, v in getattr(obj, "__dict__", obj).items():
            new_dict[k] = as_nested_dict(v, dct_class)
        return new_dict
    return obj


# Compound key operations


class CompoundKey(tuple):
    """A tuple subclass used for representing nested dictionary keys."""

    pass


def dict_to_flatdict(dct: DictLike, parent: CompoundKey | None = None) -> dict:
    """
    Convert a nested dictionary to a flat dictionary with compound keys.

    Args:
        dct (DictLike): Nested dictionary to flatten.
        parent (CompoundKey | None, optional): Parent key for recursive calls. Defaults to None.

    Returns:
        dict: Flattened dictionary with compound keys.
    """
    items = []
    parent = parent or CompoundKey()
    for k, v in dct.items():
        k_parent = CompoundKey(parent + (k,))
        if isinstance(v, dict):
            items.extend(dict_to_flatdict(v, parent=k_parent).items())
        else:
            items.append((k_parent, v))
    return dict(items)


def flatdict_to_dict(dct: dict, dct_class: typing.Type[D] | None = None) -> D:
    """
    Convert a flat dictionary with compound keys back to a nested dictionary.

    Args:
        dct (dict): Flat dictionary with compound keys.
        dct_class (typing.Type[D] | None, optional): Dictionary class to use for the result. Defaults to None.

    Returns:
        D: Nested dictionary of the specified class.
    """
    result = typing.cast(D, (dct_class or dict)())
    for k, v in dct.items():
        if isinstance(k, CompoundKey):
            current_dict = result
            for ki in k[:-1]:
                current_dict = current_dict.setdefault(  # type: ignore
                    ki, (dct_class or dict)()
                )
            current_dict[k[-1]] = v
        else:
            result[k] = v

    return result


# Utility function for flattening sequences
def flatten_seq(seq: Iterable) -> typing.Generator:
    """
    Flatten a nested sequence (e.g., list of lists) into a single level.

    Args:
        seq (Iterable): Nested sequence to flatten.

    Yields:
        typing.Any: Elements from the flattened sequence.

    Example:
        >>> list(flatten_seq([1, [2, 3, [4, 5]], 6]))
        [1, 2, 3, 4, 5, 6]
    """
    for item in seq:
        if isinstance(item, Iterable) and not isinstance(item, (str, bytes)):
            yield from flatten_seq(item)
        else:
            yield item
