import os
import sys
from contextlib import contextmanager
from importlib.abc import Loader, MetaPathFinder
from importlib.machinery import ModuleSpec
from types import ModuleType
from typing import Any, Iterator, Sequence, Generator, Optional, List


class MockObject:
    """
    Generic object that can be used to mock anything.
    """

    __display_name__ = "MockObject"
    __name__ = ""
    __decorator_args__: tuple[Any, ...] = ()

    def __new__(cls, *args: Any, **_) -> Any:
        if len(args) == 3 and isinstance(args[1], tuple):
            superclass = args[1][-1].__class__
            if superclass is cls:
                # subclassing MockObject
                return _make_subclass(
                    args[0],
                    superclass.__display_name__,
                    superclass=superclass,
                    attributes=args[2],
                )

        return super().__new__(cls)

    def __init__(self, *_, **__) -> None:
        self.__qualname__ = self.__name__

    def __len__(self) -> int:
        """
        Override __len__ so it returns zero.
        """
        return 0

    def __contains__(self, _: str) -> bool:
        """
        Override __contains__ so it always returns False.
        """
        return False

    def __iter__(self) -> Iterator:
        """
        Override __iter__ so it always returns an empty iterator.
        """
        return iter([])

    def __mro_entries__(self, _: tuple) -> tuple:
        """
        Override __mro_entries__ so it always returns a tuple containing the
        class itself.
        """
        return (self.__class__,)

    def __getitem__(self, key: Any) -> "MockObject":
        """
        Override __getitem__ so it always returns a new MockObject.
        """
        return _make_subclass(str(key), self.__display_name__, self.__class__)()

    def __getattr__(self, key: str) -> "MockObject":
        """
        Override __getattr__ so it always returns a new MockObject.
        """
        return _make_subclass(key, self.__display_name__, self.__class__)()

    def __call__(self, *args: Any, **_) -> Any:
        """
        Override __call__ so it always returns a new MockObject.
        """
        call = self.__class__()
        call.__decorator_args__ = args
        return call

    def __repr__(self) -> str:
        """
        Override __repr__ to return the display name.
        """
        return self.__display_name__


def _make_subclass(
    name: str,
    module: str,
    superclass: Any = MockObject,
    attributes: Any = None,
    decorator_args: tuple = (),
) -> Any:
    """
    Utility method that creates a mock subclass on the fly given its
    parameters.
    """
    attrs = {
        "__module__": module,
        "__display_name__": module + "." + name,
        "__name__": name,
        "__decorator_args__": decorator_args,
    }

    attrs.update(attributes or {})
    return type(name, (superclass,), attrs)


# pylint: disable=too-few-public-methods
class MockModule(ModuleType):
    """
    Object that can be used to mock any module.
    """

    __file__ = os.devnull

    def __init__(self, name: str):
        super().__init__(name)
        self.__all__ = []
        self.__path__ = []

    def __getattr__(self, name: str):
        """
        Override __getattr__ so it always returns a new MockObject.
        """
        return _make_subclass(name, self.__name__)()

    def __mro_entries__(self, _: tuple) -> tuple:
        """
        Override __mro_entries__ so it always returns a tuple containing the
        class itself.
        """
        return (self.__class__,)


class MockFinder(MetaPathFinder):
    """A finder for mocking."""

    def __init__(self, modules: Sequence[str]) -> None:
        super().__init__()
        self.modules = modules
        self.loader = MockLoader(self)
        self.mocked_modules: List[str] = []

    def find_spec(
        self,
        fullname: str,
        path: Sequence[Optional[bytes]] | None,
        target: Optional[ModuleType] = None,
    ) -> ModuleSpec | None:
        for modname in self.modules:
            # check if fullname is (or is a descendant of) one of our targets
            if modname == fullname or fullname.startswith(modname + "."):
                return ModuleSpec(fullname, self.loader)

        return None

    def invalidate_caches(self) -> None:
        """Invalidate mocked modules on sys.modules."""
        for modname in self.mocked_modules:
            sys.modules.pop(modname, None)


class MockLoader(Loader):
    """A loader for mocking."""

    def __init__(self, finder: MockFinder) -> None:
        super().__init__()
        self.finder = finder

    def create_module(self, spec: ModuleSpec) -> ModuleType:
        self.finder.mocked_modules.append(spec.name)
        return MockModule(spec.name)

    def exec_module(self, module: ModuleType) -> None:
        pass  # nothing to do


@contextmanager
def mock(*modules: str) -> Generator[None, None, None]:
    """
    Insert mock modules during context::

    with mock('target.module.name'):
        # mock modules are enabled here
        ...
    """
    finder = None
    try:
        finder = MockFinder(modules)
        sys.meta_path.insert(0, finder)
        yield
    finally:
        if finder:
            sys.meta_path.remove(finder)
            finder.invalidate_caches()
