"""defined type hints for app composability"""

from __future__ import annotations

from pathlib import Path
from types import UnionType
from typing import (
    TYPE_CHECKING,
    Any,
    ForwardRef,
    Protocol,
    TypeVar,
    Union,
    get_args,
    get_origin,
    runtime_checkable,
)

from scinexus.data_store import DataMemberABC

if TYPE_CHECKING:
    from collections.abc import Callable

NESTED_HINTS = (Union, UnionType, list, tuple, set)

_type_namespace_providers: list[Callable[[], dict[str, type]]] = []


def register_type_namespace(
    provider: Callable[[], dict[str, type]],
) -> None:
    """register a lazy namespace provider for forward-reference resolution

    Parameters
    ----------
    provider
        a zero-arg callable returning a dict of {name: type}. It is invoked
        lazily (each time _resolve_name needs a fallback) so downstream
        packages can defer heavy imports. Providers are responsible for
        their own caching.

    Notes
    -----
    Registration is idempotent: re-registering the same callable is a
    no-op. Providers are consulted in registration order, and the first
    provider that yields ``name`` wins.
    """
    if provider not in _type_namespace_providers:
        _type_namespace_providers.append(provider)


def _clear_type_namespace_providers() -> None:
    """remove all registered namespace providers (intended for tests)"""
    _type_namespace_providers.clear()


@runtime_checkable
class HasSource(Protocol):
    @property
    def source(self) -> Any: ...


@runtime_checkable
class HasInfo(Protocol):
    @property
    def info(self) -> HasSource: ...


@runtime_checkable
class SerialisableType(Protocol):
    """a runtime-checkable protocol for objects that serialise to a dict

    Any object that implements a ``to_rich_dict`` method returning
    ``dict[str, object]`` satisfies this protocol. Writer apps rely on
    this to convert results before storing them in a data store -- both
    ``DataStoreDirectory`` and ``DataStoreSqlite`` call ``to_rich_dict()``
    during the write path.
    """

    def to_rich_dict(self) -> dict[str, object]: ...


IdentifierType = str | Path | DataMemberABC
"""accepted types for identifying members of a data store

A loader app can receive a file path as a string, a ``pathlib.Path``,
or a ``DataMemberABC`` handle returned by iterating over a data store."""


def _resolve_name(
    name: str, module_globals: dict[str, object] | None = None
) -> type[Any]:
    """resolves a string name to a type

    Checks ``module_globals`` first, then falls back to any namespace
    providers registered via :func:`register_type_namespace`.
    """
    if module_globals and name in module_globals:
        result = module_globals[name]
        if isinstance(result, type):
            return result

    for provider in _type_namespace_providers:
        ns = provider()
        if name in ns:
            result = ns[name]
            if isinstance(result, type):
                return result

    msg = f"cannot resolve type name {name!r}"
    raise TypeError(msg)


def resolve_type_hint(
    hint: object, module_globals: dict[str, object] | None = None
) -> object:
    """walks a type hint tree and resolves all forward references to classes

    Parameters
    ----------
    hint
        a type hint (TypeVar, Union, ForwardRef, str, or concrete class)
    module_globals
        optional dict of the module where the hint was defined, used
        to resolve forward references from user code
    """
    # Protocol classes (like SerialisableType) -- return as-is
    if getattr(hint, "_is_protocol", False) and hint is not Protocol:
        return hint

    # TypeVar with __bound__ -> resolve bound class
    if isinstance(hint, TypeVar):
        if hint.__bound__:
            bound = hint.__bound__
            if isinstance(bound, ForwardRef):
                bound = _resolve_name(bound.__forward_arg__, module_globals)
            return bound
        if hint.__constraints__:
            resolved = tuple(
                resolve_type_hint(c, module_globals) for c in hint.__constraints__
            )
            return Union[resolved]  # type: ignore  # noqa: UP007
        msg = f"unconstrained TypeVar {hint!r} cannot be resolved"
        raise TypeError(msg)

    # Union / UnionType -> recurse
    origin = get_origin(hint)
    if origin is Union or isinstance(hint, UnionType):
        args = tuple(resolve_type_hint(a, module_globals) for a in get_args(hint))
        return Union[args]  # type: ignore  # noqa: UP007

    # Container types (list[X], tuple[X,Y], set[X])
    if origin in (list, tuple, set):
        args = tuple(resolve_type_hint(a, module_globals) for a in get_args(hint))
        return origin[args] if args else hint

    # ForwardRef
    if isinstance(hint, ForwardRef):
        return _resolve_name(hint.__forward_arg__, module_globals)

    # plain str
    return _resolve_name(hint, module_globals) if isinstance(hint, str) else hint


def get_type_display_names(hint: object) -> frozenset[str]:
    """extracts human-readable class names from a resolved type hint

    Parameters
    ----------
    hint
        a resolved type hint (one that has been through resolve_type_hint)
    """
    names: set[str] = set()
    origin = get_origin(hint)

    if origin is Union or isinstance(hint, UnionType) or origin in (list, tuple, set):
        for arg in get_args(hint):
            names |= get_type_display_names(arg)
    elif isinstance(hint, type):
        names.add(hint.__name__)
    elif isinstance(hint, TypeVar):
        # fallback for unresolved TypeVars -- shouldn't normally happen
        names.add(hint.__name__)

    return frozenset(names)


def _get_concrete_classes(hint: object) -> set[type[Any]]:
    """extracts concrete classes from a resolved type hint, walking Unions"""
    classes = set()
    origin = get_origin(hint)

    if origin is Union or isinstance(hint, UnionType):
        for arg in get_args(hint):
            classes |= _get_concrete_classes(arg)
    elif origin in (list, tuple, set):
        classes.add(origin)
    elif isinstance(hint, type):
        classes.add(hint)

    return classes


def _is_protocol(hint: object) -> bool:
    """checks if a type hint is or contains a runtime-checkable Protocol"""
    if getattr(hint, "_is_protocol", False) and hint is not Protocol:
        return True

    origin = get_origin(hint)
    if origin is Union or isinstance(hint, UnionType):
        return any(_is_protocol(a) for a in get_args(hint))

    return False


def check_type_compatibility(return_hint: object, input_hint: object) -> bool:
    """composition-time check: is the return type compatible with the input type?

    Parameters
    ----------
    return_hint
        resolved return type of the upstream app
    input_hint
        resolved input type of the downstream app

    Returns
    -------
    True if the types are compatible, False otherwise
    """
    # typing.Any is compatible with everything
    if return_hint is Any or input_hint is Any:
        return True

    # If either side is or contains a Protocol, be lenient -- runtime check_type
    # provides the real safety net
    if _is_protocol(return_hint) or _is_protocol(input_hint):
        return True

    return_classes = _get_concrete_classes(return_hint)
    input_classes = _get_concrete_classes(input_hint)

    # Check if any return class is a subclass of any input class (or vice versa)
    for ret_cls in return_classes:
        for inp_cls in input_classes:
            try:
                if issubclass(ret_cls, inp_cls) or issubclass(inp_cls, ret_cls):
                    return True
            except TypeError:
                # issubclass can fail for some types
                if ret_cls is inp_cls:
                    return True

    return False
