# SPDX-License-Identifier: ISC

# Copyright (c) 2021, Timothée Mazzucotelli and contributors

# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.

# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.

# This module contains utilities for extracting information from runtime objects.

from __future__ import annotations

import inspect
import sys
import typing
from functools import cached_property
from types import GetSetDescriptorType, ModuleType
from typing import TYPE_CHECKING, Any, ClassVar

from griffe._internal.enumerations import ObjectKind
from griffe._internal.logger import logger

if TYPE_CHECKING:
    import types
    from collections.abc import Sequence

_TYPING_MODULES: tuple[types.ModuleType, ...]
try:
    import typing_extensions
except ImportError:
    _TYPING_MODULES = (typing,)
else:
    _TYPING_MODULES = (typing, typing_extensions)


_builtin_module_names = {_.lstrip("_") for _ in sys.builtin_module_names}
_cyclic_relationships = {
    ("os", "nt"),
    ("os", "posix"),
    ("numpy.core._multiarray_umath", "numpy.core.multiarray"),
}


def _same_components(a: str, b: str, /) -> bool:
    return [cpn.lstrip("_") for cpn in a.split(".")] == [cpn.lstrip("_") for cpn in b.split(".")]


class ObjectNode:
    """Helper class to represent an object tree.

    It's not really a tree but more a backward-linked list:
    each node has a reference to its parent, but not to its child (for simplicity purposes and to avoid bugs).

    Each node stores an object, its name, and a reference to its parent node.
    """

    exclude_specials: ClassVar[set[str]] = {"__builtins__", "__loader__", "__spec__"}
    """Low level attributes known to cause issues when resolving aliases."""

    def __init__(self, obj: Any, name: str, parent: ObjectNode | None = None) -> None:
        """Initialize the object.

        Parameters:
            obj: A Python object.
            name: The object's name.
            parent: The object's parent node.
        """
        # Unwrap object.
        try:
            obj = inspect.unwrap(obj)
        except Exception as error:  # noqa: BLE001
            # `inspect.unwrap` at some point runs `hasattr(obj, "__wrapped__")`,
            # which triggers the `__getattr__` method of the object, which in
            # turn can raise various exceptions. Probably not just `__getattr__`.
            # See https://github.com/pawamoy/pytkdocs/issues/45.
            logger.debug("Could not unwrap %s: %r", name, error)

        # Unwrap cached properties (`inspect.unwrap` doesn't do that).
        if isinstance(obj, cached_property):
            is_cached_property = True
            obj = obj.func
        else:
            is_cached_property = False

        self.obj: Any = obj
        """The actual Python object."""
        self.name: str = name
        """The Python object's name."""
        self.parent: ObjectNode | None = parent
        """The parent node."""
        self.is_cached_property: bool = is_cached_property
        """Whether this node's object is a cached property."""

    def __repr__(self) -> str:
        return f"ObjectNode(name={self.name!r})"

    @property
    def path(self) -> str:
        """The object's (Python) path."""
        if self.parent is None:
            return self.name
        return f"{self.parent.path}.{self.name}"

    @property
    def module(self) -> ObjectNode:
        """The object's module, fetched from the node tree."""
        if self.is_module:
            return self
        if self.parent is not None:
            return self.parent.module
        raise ValueError(f"Object node {self.path} does not have a parent module")

    @property
    def module_path(self) -> str | None:
        """The object's module path."""
        try:
            module = self.obj.__module__
        except AttributeError:
            try:
                module = inspect.getmodule(self.obj) or self.module.obj
            except ValueError:
                return None
            try:
                return module.__spec__.name  # ty:ignore[unresolved-attribute]
            except AttributeError:
                return getattr(module, "__name__", None)
        else:
            # Some libraries mistakenly set `__module__` to a module object instead of a string.
            # Handle this case specifically, but do not cast any other object to a string.
            if isinstance(module, ModuleType):
                logger.debug(
                    "Object %s has its `__module__` attribute set to a module object: "
                    "it must be a string instead (the module fully qualified name). "
                    "Please report to the maintainers of this library.",
                    self.path,
                )
                return getattr(module, "__qualname__", getattr(module, "__name__", None))
            if isinstance(module, str):
                return module
            logger.debug(
                "Object %s has its `__module__` attribute set to a %s object: "
                "it must be a string instead (the module fully qualified name). "
                "Please report to the maintainers of this library.",
                self.path,
                getattr(type(module), "__name__", str(type(module))),
            )
            return None

    @property
    def kind(self) -> ObjectKind:
        """The kind of this node."""
        if self.is_module:
            return ObjectKind.MODULE
        if self.is_class:
            return ObjectKind.CLASS
        if self.is_staticmethod:
            return ObjectKind.STATICMETHOD
        if self.is_classmethod:
            return ObjectKind.CLASSMETHOD
        if self.is_cached_property:
            return ObjectKind.CACHED_PROPERTY
        if self.is_method:
            return ObjectKind.METHOD
        if self.is_builtin_method:
            return ObjectKind.BUILTIN_METHOD
        if self.is_coroutine:
            return ObjectKind.COROUTINE
        if self.is_builtin_function:
            return ObjectKind.BUILTIN_FUNCTION
        if self.is_method_descriptor:
            return ObjectKind.METHOD_DESCRIPTOR
        if self.is_function:
            return ObjectKind.FUNCTION
        if self.is_getset_descriptor:
            return ObjectKind.GETSET_DESCRIPTOR
        if self.is_property:
            return ObjectKind.PROPERTY
        if self.is_type_alias:
            return ObjectKind.TYPE_ALIAS
        return ObjectKind.ATTRIBUTE

    @cached_property
    def children(self) -> Sequence[ObjectNode]:
        """The children of this node."""
        children = []
        for name, member in inspect.getmembers(self.obj):
            if self._pick_member(name, member):
                children.append(ObjectNode(member, name, parent=self))
        return children

    @cached_property
    def is_module(self) -> bool:
        """Whether this node's object is a module."""
        return inspect.ismodule(self.obj)

    @cached_property
    def is_class(self) -> bool:
        """Whether this node's object is a class."""
        return inspect.isclass(self.obj)

    @cached_property
    def is_function(self) -> bool:
        """Whether this node's object is a function."""
        # `inspect.isfunction` returns `False` for partials.
        return inspect.isfunction(self.obj) or (callable(self.obj) and not self.is_class)

    @cached_property
    def is_type_alias(self) -> bool:
        """Whether this node's object is a type alias."""
        return isinstance(
            self.obj,
            tuple(module.TypeAliasType for module in _TYPING_MODULES if hasattr(module, "TypeAliasType")),
        )

    @cached_property
    def is_builtin_function(self) -> bool:
        """Whether this node's object is a builtin function."""
        return inspect.isbuiltin(self.obj)

    @cached_property
    def is_coroutine(self) -> bool:
        """Whether this node's object is a coroutine."""
        return inspect.iscoroutinefunction(self.obj)

    @cached_property
    def is_getset_descriptor(self) -> bool:
        """Whether this node's object is a get/set descriptor."""
        return isinstance(self.obj, GetSetDescriptorType)

    @cached_property
    def is_property(self) -> bool:
        """Whether this node's object is a property."""
        return isinstance(self.obj, property) or self.is_cached_property

    @cached_property
    def parent_is_class(self) -> bool:
        """Whether the object of this node's parent is a class."""
        return bool(self.parent and self.parent.is_class)

    @cached_property
    def is_method(self) -> bool:
        """Whether this node's object is a method."""
        function_type = type(lambda: None)
        return self.parent_is_class and isinstance(self.obj, function_type)

    @cached_property
    def is_method_descriptor(self) -> bool:
        """Whether this node's object is a method descriptor.

        Built-in methods (e.g. those implemented in C/Rust) are often
        method descriptors, rather than normal methods.
        """
        return inspect.ismethoddescriptor(self.obj)

    @cached_property
    def is_builtin_method(self) -> bool:
        """Whether this node's object is a builtin method."""
        return self.is_builtin_function and self.parent_is_class

    @cached_property
    def is_staticmethod(self) -> bool:
        """Whether this node's object is a staticmethod."""
        if self.parent is None:
            return False
        try:
            self_from_parent = self.parent.obj.__dict__.get(self.name, None)
        except AttributeError:
            return False
        return self.parent_is_class and isinstance(self_from_parent, staticmethod)

    @cached_property
    def is_classmethod(self) -> bool:
        """Whether this node's object is a classmethod."""
        if self.parent is None:
            return False
        try:
            self_from_parent = self.parent.obj.__dict__.get(self.name, None)
        except AttributeError:
            return False
        return self.parent_is_class and isinstance(self_from_parent, classmethod)

    @cached_property
    def is_attribute(self) -> bool:
        """Whether this node's object is an attribute."""
        return self.kind is ObjectKind.ATTRIBUTE

    @cached_property
    def _ids(self) -> set[int]:
        if self.parent is None:
            return {id(self.obj)}
        return {id(self.obj)} | self.parent._ids

    def _pick_member(self, name: str, member: Any) -> bool:
        return (
            name not in self.exclude_specials
            and member is not type
            and member is not object
            and id(member) not in self._ids
            and name in vars(self.obj)
        )

    @cached_property
    def alias_target_path(self) -> str | None:
        """Alias target path of this node, if the node should be an alias."""
        # Top-level objects can't have been imported.
        if self.parent is None:
            return None

        # We can't ever know if an attribute was imported.
        if self.is_attribute:
            return None

        # Get the path of the module the child object was declared in.
        child_module_path = self.module_path
        if not child_module_path:
            return None

        # Get the path of the module the parent object was declared in.
        parent_module_path = self.parent.module_path
        if not parent_module_path:
            return None

        # Special cases: break cycles.
        if (parent_module_path, child_module_path) in _cyclic_relationships:
            return None

        # If the current object was declared in the same module as its parent,
        # or in a module with the same path components but starting/not starting with underscores,
        # we don't want to alias it. Examples: (a, a), (a, _a), (_a, a), (_a, _a),
        # (a.b, a.b), (a.b, _a.b), (_a._b, a.b), (a._b, _a.b), etc..
        if _same_components(parent_module_path, child_module_path):
            return None

        # If the current object was declared in any other module, we alias it.
        # We remove the leading underscore from the child module path
        # if it's a built-in module (e.g. _io -> io). That's because objects
        # in built-in modules inconsistently lie about their module path,
        # so we prefer to use the non-underscored (public) version,
        # as users most likely import from the public module and not the private one.
        if child_module_path.lstrip("_") in _builtin_module_names:
            child_module_path = child_module_path.lstrip("_")

        # Child object is a module, return its path directly.
        if self.is_module:
            return child_module_path

        # Rebuild the child object path.
        child_name = getattr(self.obj, "__qualname__", self.path[len(self.module.path) + 1 :])
        return f"{child_module_path}.{child_name}"
