"""Serialise objects to a stable representation."""

from __future__ import annotations

import hashlib
import json
import types
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from typing import Any


def stable_hash(obj: Any) -> str:
    """Return a stable hash for a Python data structure.

    We can't just use the md5 of str(obj) as the order of collections
    may be random.
    """
    if isinstance(obj, dict):
        obj = sorted(map(stable_hash, obj.items()))
    if isinstance(obj, (list, tuple, set, frozenset)):
        obj = sorted(map(stable_hash, obj))
    elif isinstance(obj, (type, types.FunctionType)):
        # The default repr() of functions includes the ID, which is not ideal.
        # We use the fully qualified name instead.
        obj = f'{obj.__module__}.{obj.__qualname__}'
    return hashlib.md5(str(obj).encode(), usedforsecurity=False).hexdigest()


def stable_str(obj: Any, *, indent: int | None = None) -> str:
    """Return a stable string representation of a Python data structure.

    We can't just use ``str(obj)`` as the order of collections may be random.
    """
    return json.dumps(_stable_str_prep(obj), indent=indent)


def _stable_str_prep(obj: Any) -> dict[str, Any] | list[Any] | str:
    if isinstance(obj, dict):
        # Convert to a sorted dict
        lst: list[tuple[Any, Any]] = [
            (_stable_str_prep(k), _stable_str_prep(v)) for k, v in obj.items()
        ]
        lst.sort()
        return dict(lst)
    if isinstance(obj, (list, tuple, set, frozenset)):
        # Convert to a sorted list
        return sorted(map(_stable_str_prep, obj), key=str)  # ty: ignore[no-matching-overload]
    if isinstance(obj, (type, types.FunctionType)):
        # The default repr() of functions includes the ID, which is not ideal.
        # We use the fully qualified name instead.
        return f'{obj.__module__}.{obj.__qualname__}'
    # We can't do any better, just use the string representation
    return str(obj)
